summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 10:09:54 -0300
committerGabriel Schneider <[email protected]>2026-10-01 10:36:28 -0300
commitedad6d960dc0fc1165cbb3b801e0538045b2247f (patch)
tree0bedfbb9cd371e5d77cb697438c5198c688653c5
parent4db57bf5bd788a292fcf2cfbea3b6d8b281510f0 (diff)
downloadpardes-edad6d960dc0fc1165cbb3b801e0538045b2247f.tar.gz
pardes-edad6d960dc0fc1165cbb3b801e0538045b2247f.zip
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
-rw-r--r--src/9p_io.zig28
-rw-r--r--src/File.zig40
-rw-r--r--src/config.zig8
-rw-r--r--src/fs.zig7
-rw-r--r--src/main.zig129
-rw-r--r--src/ninep/ctl.zig103
-rw-r--r--src/ninep/tree.zig58
-rw-r--r--src/pager.zig373
-rw-r--r--src/pardes.zig12
-rw-r--r--test/fs.py20
-rw-r--r--test/selfmount.py14
-rw-r--r--test/snapshots/pagercolor.golden120
-rw-r--r--test/snapshots/pagercolor.snap24
13 files changed, 806 insertions, 130 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 9a38b438..57df0c5f 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -266,6 +266,7 @@ pub const Listener = struct {
const restores = pardes.turn.restores;
// Asked first: a release that runs a held line has none after.
const changes = pardes.ctlfs.changesPane(core, req);
+ const fills = pardes.ctlfs.holdsPage(core, req);
const reply = core.serveFs(req);
if (req.op == .release and quiet) l.collectOs();
// A read with nothing yet stays parked in the engine, and the core
@@ -299,6 +300,13 @@ pub const Listener = struct {
// file written. A change carries no payload, so the reply is still
// whole after the wait.
l.kick();
+ // A read that put a /pager open's text in (ctl.fillPager), or a
+ // close that handed it to the editor's step, waits on nothing the
+ // editor performs: its answer goes now, while the turn still keeps
+ // a read's payload whole. Waiting would hang a close through a
+ // mount: 9ns answers nothing else on it while a clunk is out, and
+ // the editor's step may be out opening a file through that mount.
+ if (fills) return conn.reply(&reply, core.fsPayload(reply));
// A refusal answers at once: its text may be in the core's one
// buffer for it, which a request run while this one waited would
// write over.
@@ -2341,10 +2349,12 @@ pub const Client = struct {
gpa.destroy(s);
}
- /// Writes `request` to `path` and reads the answer on the same open,
- /// as /pane/new answers its own open: a session's last answer, from
- /// another client, can never be what comes back. `exec 3<>file`.
- pub fn ask(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, request: []const u8) ![]u8 {
+ /// Writes `request`, then `more`, to `path` and reads the answer on the
+ /// same open, as /pane/new answers its own open: a session's last
+ /// answer, from another client, can never be what comes back. `exec
+ /// 3<>file`. `more` (`pardes -`'s text after its /pager line) goes in
+ /// Twrites at rising offsets, each answer giving the next its budget.
+ pub fn ask(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, request: []const u8, more: []const u8) ![]u8 {
if (comptime !supported) return error.Unsupported;
var names: [max_depth][]const u8 = undefined;
const n = try elements(path, &names);
@@ -2356,7 +2366,15 @@ pub const Client = struct {
defer endSession(gpa, s);
const fid = (try walkTo(s, names[0..n], &remote)).fid;
_ = try s.ask(.{ .open = .{ .fid = fid, .mode = ninep.ordwr } }, &remote);
- _ = try s.ask(.{ .write = .{ .fid = fid, .offset = 0, .data = request } }, &remote);
+ var written: usize = 0;
+ while (written < request.len + more.len) {
+ const from = if (written < request.len) request[written..] else more[written - request.len ..];
+ const chunk = from[0..@min(from.len, s.cl.maxWrite())];
+ const count = (try s.ask(.{ .write = .{ .fid = fid, .offset = written, .data = chunk } }, &remote)).write;
+ if (count == 0 or count > chunk.len) return Error.Botch;
+ written += count;
+ s.deadline = nowMs() +| write_budget_ms;
+ }
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(gpa);
while (true) {
diff --git a/src/File.zig b/src/File.zig
index 4fac0073..b3c28290 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -75,6 +75,11 @@ pub const State = struct {
/// A +Pager pane (`pardes -`) whose text is a diff (`git diff`, `show`,
/// `log -p`): drawn and looked at as a diff (Pane.colorAlgo).
pager_diff: bool = false,
+ /// A +Pager's colours, as its program printed them (pager.zig): drawn
+ /// over its text by recolorSyntax, never read over 9P. ponytail: any
+ /// edit drops them (setContentSpan); shifting them through the splice,
+ /// as pane.zig's shiftOne moves dot, would keep them.
+ pager_spans: []pardes.pager.Span = &.{},
/// Seconds of the last content change, for the filesystem's stat; zero until edited.
mtime: u32 = 0,
/// Non-null for a generated output buffer rather than an on-disk file.
@@ -982,6 +987,7 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.mini) |*mini| mini.deinit(p.gpa);
if (file.line_starts.len > 0) p.gpa.free(file.line_starts);
if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
+ p.gpa.free(file.pager_spans);
locations.freeRows(p.gpa, file.location_rows);
if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations);
if (file.node_tree) |tree| syntax.freeTree(tree);
@@ -1010,6 +1016,8 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void {
locations.freeRows(p.gpa, f.location_rows);
f.location_rows = &.{};
+ p.gpa.free(f.pager_spans);
+ f.pager_spans = &.{};
for (p.panes) |slot| if (slot) |pane| {
if (pane.file) |*file| if (file == f) {
for (0..pane.sel.len) |button| {
@@ -1483,7 +1491,8 @@ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void {
const highlights = if (f.mini) |mini| mini.colors else f.highlights;
const highlight_start = if (f.mini != null) 0 else f.highlight_start;
- if (highlights.len == 0 and f.output == null) return;
+ const spans = f.pager_spans;
+ if (highlights.len == 0 and f.output == null and spans.len == 0) return;
const prefix_width = gutterWidth(pane);
const tz_recolor = tracy.zone(@src(), "synRecolor");
defer tz_recolor.end();
@@ -1526,6 +1535,14 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
else
null else null;
if (tint) |t| if (prefix_width < tw) tintRow(s, tx + @as(u16, @intCast(prefix_width)), body_y + vr, tx + tw, t.bg);
+ // A +Pager's own colours (pager.zig) on every row but one the diff
+ // look tints: an added or removed line is drawn as pardes draws a
+ // diff, and git's red and green on it would only fight the tint.
+ var span_at = if (tint != null) spans.len else std.sort.partitionPoint(pardes.pager.Span, spans, base + hs, struct {
+ fn before(at: usize, span: pardes.pager.Span) bool {
+ return span.end <= at;
+ }
+ }.before);
var c: usize = 0;
var screen_c: usize = 0;
while (hs + c < limit and prefix_width + screen_c < tw) {
@@ -1562,6 +1579,27 @@ pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.
if (style.bg != .default) cell.style.bg = style.bg;
}
}
+ while (span_at < spans.len and spans[span_at].end <= idx) span_at += 1;
+ if (span_at < spans.len and spans[span_at].start <= idx) {
+ const st = spans[span_at].style;
+ var fill: usize = 0;
+ while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr);
+ if (cell.default) continue;
+ // A palette colour through the theme's sixteen, as a
+ // terminal pane's cell; truecolor as it is.
+ if (st.fg != .default) cell.style.fg = if (st.fg == .index) terminal.palColor(p, st.fg.index) else st.fg;
+ if (st.bg != .default) cell.style.bg = if (st.bg == .index) terminal.palColor(p, st.bg.index) else st.bg;
+ cell.style.bold = cell.style.bold or st.bold;
+ cell.style.dim = st.dim;
+ cell.style.italic = st.italic;
+ cell.style.blink = st.blink;
+ cell.style.reverse = st.reverse;
+ cell.style.invisible = st.invisible;
+ cell.style.strikethrough = st.strikethrough;
+ if (st.ul != .off) cell.style.ul = st.ul;
+ }
+ }
screen_c += cells;
c = grapheme_end - hs;
}
diff --git a/src/config.zig b/src/config.zig
index 7559d481..86baa9fe 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -82,6 +82,7 @@ pub const leader_path = paths: {
.BootShell = null,
.LookWord = null,
.Pager = null,
+ .PagerColor = null,
.TermImages = null,
.Debug = "td",
.Msg = null,
@@ -762,6 +763,9 @@ pub const Runtime = struct {
/// PAGER and GIT_PAGER set to `pardes -`, a +Pager pane, where the
/// user's environment set neither; `off`, the environment as it is.
pager: Pager = .pardes,
+ /// A +Pager keeps its program's colours (SGR, as spans over the text,
+ /// pager.zig), or is plain; for the next text paged.
+ pager_color: bool = true,
/// How a new terminal pane draws the kitty graphics its program sends
/// (yazi's previews): `real`, as pixels where the shell can, or
/// `petscii`, as glyph art. The Petscii builtin flips one pane.
@@ -939,7 +943,7 @@ pub const Runtime = struct {
/// The message timings a setting word sets, each in milliseconds.
pub const MessageTime = enum { linger, fall, dissolve };
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow, hover_glow, occlusion, parallax, jump_trail, chip_shadow, thumb_flash };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, pager_color, verbose, message_animation, ligatures, cursor_blink, selection_glow, hover_glow, occlusion, parallax, jump_trail, chip_shadow, thumb_flash };
/// A bundled post pass, a Shadertoy file in shaders/post/: the CRT, and
/// G4's bloom, vignette and grain (docs/effects.md).
pub const Scene = enum { crt, bloom, vignette, grain };
@@ -1040,6 +1044,7 @@ pub const Runtime = struct {
.{ .word = "BootShell", .action = .{ .choice = .boot_shell }, .doc = "A document dropped where a lone untouched shell stands: keep the shell, or replace it." },
.{ .word = "LookWord", .action = .{ .choice = .look_word }, .doc = "A plain word looked at: search selects its next place in the pane, list lists every place in a +Search." },
.{ .word = "Pager", .action = .{ .choice = .pager }, .doc = "What a terminal's commands page through: pardes, a +Pager pane (pardes -), or off, the environment's own; for terminals started after it." },
+ .{ .word = "PagerColor", .action = .{ .toggle = .pager_color }, .doc = "A +Pager keeps its program's colours, on or off; for the next text paged." },
.{ .word = "TermImages", .action = .{ .choice = .term_images }, .doc = "How a new terminal draws its program's kitty graphics: real, as pixels where it can, or petscii, as glyph art." },
};
@@ -1748,6 +1753,7 @@ pub const Runtime = struct {
.debug => "Debug on\n",
.focus_tint => "FocusTint off\n",
.syntax_bold => "SyntaxBold on\n",
+ .pager_color => "PagerColor off\n",
.verbose => "Verbose off\n",
.message_animation => "MessageAnimation off\n",
.ligatures => "Ligatures off\n",
diff --git a/src/fs.zig b/src/fs.zig
index 9eb3fd0f..4a243b10 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1631,6 +1631,11 @@ pub const Namespace = struct {
/// append to `body`, an insert at `data`'s address), held and put in
/// as one edit when anything else comes (ninep/pane.zig flushBatch): a
/// 10 MB write in 8 KB pieces is one copy and one undo step, not 1280.
+ /// The text of /pager opens closed before a read put it in, each with
+ /// its +Pager's serial, for the editor's step to put in (ctl.fillPager):
+ /// a close through a mount may not wait for the editor to be quiet,
+ /// as 9ns answers nothing else on that mount while a clunk is out.
+ pages: std.ArrayList(struct { serial: u32, bytes: std.ArrayList(u8) }) = .empty,
batch: struct {
serial: u32 = 0,
node: u64 = 0,
@@ -1678,6 +1683,8 @@ pub const Namespace = struct {
for (st.os_paths.items) |entry| gpa.free(entry.path);
st.os_paths.deinit(gpa);
for (&st.opens) |*o| o.deinit(gpa);
+ for (st.pages.items) |*page| page.bytes.deinit(gpa);
+ st.pages.deinit(gpa);
st.log.deinit(gpa);
st.out.deinit(gpa);
}
diff --git a/src/main.zig b/src/main.zig
index e998accc..6d895307 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -50,9 +50,10 @@ const help_text =
\\
\\With no arguments the shell pane starts in tty mode. A FILE argument
\\(optionally file:LINE) opens it alone, filling the window; a DIRECTORY
- \\argument becomes the cwd shells spawn in. `-` reads stdin, its terminal
- \\escapes stripped, into a +Pager pane: inside a pardes pane the outer
- \\session's, returning at once (a terminal's PAGER), else a new editor's.
+ \\argument becomes the cwd shells spawn in. `-` reads stdin into a +Pager
+ \\pane, its colours kept (PagerColor) and every other escape dropped:
+ \\inside a pardes pane the outer session's, returning at once (a
+ \\terminal's PAGER), else a new editor's.
\\
\\Options:
\\ --tty start as one shell pane already in tty mode
@@ -100,11 +101,12 @@ const help_text =
\\
;
-/// `pardes -` inside a session: `text` into the directory's one +Pager
-/// (/pager, refilled when it is there), written into its
-/// body through one open, as one undo step, and left clean. A text
-/// the session would not take is never lost: it goes to stderr, with why,
-/// and the exit is 1.
+/// `pardes -` inside a session: `text`, stdin as it came, escapes and all,
+/// into the directory's one +Pager (/pager, refilled when it is there):
+/// written after the directory on that one open, the session parses it
+/// (pager.zig) and puts it in as one undo step, left clean. A text the
+/// session would not take is never lost: it goes to stderr, a terminal,
+/// with why, and the exit is 1.
fn pageInto(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, text: []const u8) void {
const Fail = struct {
fn with(o: std.Io, t: []const u8, what: []const u8, err: anyerror) noreturn {
@@ -128,22 +130,16 @@ fn pageInto(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, text: []const
} else !pardes.filesystem.deniedAbove(cwd);
var line_buf: [4200]u8 = undefined;
const dir_line = std.fmt.bufPrint(&line_buf, "{s}\n", .{if (plain) cwd else ""}) catch "\n";
- // Its serial read back on the open that asked: another client's exec
- // meanwhile can never send this text into that client's pane.
- const answer = ninep_io.Client.ask(gpa, dial, "/pager", dir_line) catch |err| Fail.with(io, text, "no +Pager pane", err);
- const serial = std.fmt.parseInt(u32, std.mem.trim(u8, answer, " \n"), 10) catch |err| Fail.with(io, text, "no +Pager pane", err);
- // The whole text through one open of the body (truncated once, then
- // written at rising offsets): one undo step, and every byte of it.
- var body_buf: [64]u8 = undefined;
- const body = std.fmt.bufPrint(&body_buf, "/pane/{d}/body", .{serial}) catch unreachable;
- ninep_io.Client.write(gpa, dial, body, text) catch |err| Fail.with(io, text, "the +Pager pane would not take it", err);
- var ctl_buf: [64]u8 = undefined;
- ninep_io.Client.write(gpa, dial, std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}) catch unreachable, "clean\n") catch {};
+ // Its serial read back on the open that asked, once the text is in:
+ // another client's exec meanwhile can never send it into that
+ // client's pane.
+ const answer = ninep_io.Client.ask(gpa, dial, "/pager", dir_line, text) catch |err| Fail.with(io, text, "no +Pager pane", err);
+ _ = std.fmt.parseInt(u32, std.mem.trim(u8, answer, " \n"), 10) catch |err| Fail.with(io, text, "no +Pager pane", err);
}
-/// Stdin to its end, its terminal escapes stripped (a pager's input is
-/// coloured for a terminal; a text pane shows text), at most a file's
-/// limit, a longer one cut there and said so at its end.
+/// Stdin to its end, as it came (its escapes are the session's to parse,
+/// pager.zig), at most a file's limit, a longer one cut there and said so
+/// at its end.
fn readStdinText(gpa: std.mem.Allocator) ![]u8 {
const limit = pardes.memory.limits.max_file_bytes;
var raw: std.ArrayList(u8) = .empty;
@@ -164,90 +160,13 @@ fn readStdinText(gpa: std.mem.Allocator) ![]u8 {
}
try raw.appendSlice(gpa, chunk[0..got]);
}
- var text = try stripEscapes(gpa, raw.items);
if (cut) {
- const said = "\n[pardes: the rest of stdin is cut: a pane holds at most a file's limit]\n";
- text = try std.mem.concat(gpa, u8, &.{ text, said });
- }
- return text;
-}
-
-/// `bytes` without terminal escapes: CSI (`ESC [` ... a final byte, SGR
-/// colour among them), OSC (`ESC ]` ... BEL or `ESC \`), any other
-/// two-byte `ESC x`, and a man page's overstrike (`x BS x`, `_ BS x`).
-pub fn stripEscapes(gpa: std.mem.Allocator, bytes: []const u8) ![]u8 {
- var out = try std.ArrayList(u8).initCapacity(gpa, bytes.len);
- var i: usize = 0;
- while (i < bytes.len) {
- const c = bytes[i];
- if (c == 0x1b and i + 1 < bytes.len) {
- const kind = bytes[i + 1];
- i += 2;
- if (kind == '[') {
- while (i < bytes.len and !(bytes[i] >= 0x40 and bytes[i] <= 0x7e)) i += 1;
- i += 1;
- } else if (std.mem.indexOfScalar(u8, "()*+", kind) != null) {
- i += 1; // a charset's name after it
- } else if (kind == ']') {
- while (i < bytes.len) : (i += 1) {
- if (bytes[i] == 0x07) {
- i += 1;
- break;
- }
- if (bytes[i] == 0x1b and i + 1 < bytes.len and bytes[i + 1] == '\\') {
- i += 2;
- break;
- }
- }
- }
- continue;
- }
- // Overstrike: the byte before a backspace is struck over.
- if (c == 0x08) {
- if (out.items.len > 0) _ = out.pop();
- i += 1;
- continue;
- }
- // A carriage return: CRLF is a newline; alone, the line starts
- // over, as on a terminal, so a progress line keeps its last state.
- if (c == '\r') {
- if (i + 1 < bytes.len and bytes[i + 1] == '\n') {
- i += 1;
- continue;
- }
- const line_start = if (std.mem.lastIndexOfScalar(u8, out.items, '\n')) |nl| nl + 1 else 0;
- out.shrinkRetainingCapacity(line_start);
- i += 1;
- continue;
- }
- // Bytes a terminal acts on and never shows: NUL, BEL, SO, SI.
- if (c == 0 or c == 0x07 or c == 0x0e or c == 0x0f) {
- i += 1;
- continue;
- }
- out.appendAssumeCapacity(c);
- i += 1;
- }
- return out.toOwnedSlice(gpa);
-}
-
-test "stripEscapes takes out colour, other escapes and overstrike, and keeps the text" {
- const gpa = std.testing.allocator;
- const cases = [_][2][]const u8{
- .{ "\x1b[33mcommit abc\x1b[m\n", "commit abc\n" },
- .{ "a\x1b]8;;http://x\x1b\\link\x1b]8;;\x07b", "alinkb" },
- .{ "N\x08NA\x08AM\x08ME\x08E _\x08u", "NAME u" },
- .{ "plain\ttext\n", "plain\ttext\n" },
- .{ "\x1b(Bdone", "done" },
- .{ "a\r\nb\r\n", "a\nb\n" },
- .{ "10%\r50%\r100%\ndone\n", "100%\ndone\n" },
- .{ "x\x00y\x07z\x0e\x0f\n", "xyz\n" },
- };
- for (cases) |c| {
- const got = try stripEscapes(gpa, c[0]);
- defer gpa.free(got);
- try std.testing.expectEqualStrings(c[1], got);
+ // CAN ends an escape the cut left half sent, and SGR 0 its colour,
+ // so the note is never swallowed or tinted.
+ const said = "\x18\x1b[m\n[pardes: the rest of stdin is cut: a pane holds at most a file's limit]\n";
+ try raw.appendSlice(gpa, said);
}
+ return raw.toOwnedSlice(gpa);
}
/// A forwarding failure in words, never an error's name: the ones this
@@ -693,7 +612,7 @@ fn nativeMain(init: std.process.Init) !void {
const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch |err| Refuse.with(init.io, word, err);
// The pane it went to, read on the open that asked (another
// client's look meanwhile is not this one's): what --wait waits on.
- const went = ninep_io.Client.ask(arena, parent.dial, look, command) catch |err| Refuse.with(init.io, word, err);
+ const went = ninep_io.Client.ask(arena, parent.dial, look, command, "") catch |err| Refuse.with(init.io, word, err);
var rows = std.mem.tokenizeAny(u8, went, " \n");
if (rows.next()) |row| made_serial = std.fmt.parseInt(u32, row, 10) catch null;
} else {
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index cd8bc6c9..d4751191 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -650,7 +650,8 @@ pub fn readRoot(p: *Pardes, req: Req) Reply {
/// /pager, `pardes -`'s: the directory written (empty, or not absolute: the
/// session's) gets its one +Pager, made or emptied; the serial is what a
-/// read of this same open answers, never another client's.
+/// read of this same open answers, never another client's. What the open
+/// writes after that line is the text, held by tree.zig for `fillPager`.
pub fn writePager(p: *Pardes, req: Req) Reply {
// A refused write leaves its open no answer, not the last one's.
p.fs.results_len = 0;
@@ -690,6 +691,35 @@ pub fn writePager(p: *Pardes, req: Req) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+/// The text a /pager open wrote after its directory (`held`), a program's
+/// output for a terminal, put in the +Pager `serial` as `pardes -` once
+/// wrote it to the body: through ghostty-vt (pager.zig), its colours kept
+/// as the pane's spans unless PagerColor is off, one undo step, and left
+/// clean. Reads of the pane are its plain text. The held bytes go either way.
+pub fn fillPager(p: *Pardes, serial: u32, held: *std.ArrayList(u8)) error{OutOfMemory}!void {
+ if (held.items.len == 0) return;
+ defer held.clearAndFree(p.gpa);
+ const id = p.paneBySerial(serial) orelse return;
+ const pane = p.panes[id].?;
+ const f = if (pane.file) |*file| file else return;
+ const page = try pardes.pager.parse(p.gpa, held.items, p.settings.pager_color);
+ held.clearAndFree(p.gpa);
+ if (page.text.len == 0 and f.content.len == 0) return page.deinit(p.gpa);
+ if (!pane.fs.nomark) panes.File.pushUndo(p, pane);
+ panes.File.setContent(p, f, page.text);
+ f.pager_spans = page.spans;
+ f.saved_revision = f.revision;
+}
+
+/// The editor's step: the text of /pager opens closed unread goes in.
+pub fn fillClosedPagers(p: *Pardes) void {
+ for (p.fs.pages.items) |*page| {
+ fillPager(p, page.serial, &page.bytes) catch {};
+ page.bytes.deinit(p.gpa);
+ }
+ p.fs.pages.clearRetainingCapacity();
+}
+
/// One session builtin a line, at whichever pane has the keyboard as each
/// runs, as the root's look and exec click there; all are checked first.
pub fn writeRoot(p: *Pardes, req: Req) Reply {
@@ -4088,21 +4118,26 @@ test "pager answers its +Pager on the open that asked, whatever another client e
// `pardes -` once read, and wrote its text into.
try testing.expect(!std.mem.eql(u8, want, rd(p, exec, 0, 64).bytes));
try testing.expectEqualStrings(want, call(p, .{ .tag = 5, .op = .read, .node = pager, .handle = asked, .size = 64 }).bytes);
- // Refused, the same open answers nothing, not its last answer.
- try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .write, .node = pager, .handle = asked, .data = "relative\n" }).errno());
- try testing.expectEqualStrings("", call(p, .{ .tag = 7, .op = .read, .node = pager, .handle = asked, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = pager, .handle = asked, .opened = true });
+ // Refused, an open answers nothing, not the session's last answer.
+ const refused = call(p, .{ .tag = 7, .op = .open, .node = pager, .omode = 2 }).reply.handle;
+ try testing.expectEqual(E.INVAL, call(p, .{ .tag = 8, .op = .write, .node = pager, .handle = refused, .data = "relative\n" }).errno());
+ try testing.expectEqualStrings("", call(p, .{ .tag = 9, .op = .read, .node = pager, .handle = refused, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 10, .op = .release, .node = pager, .handle = refused, .opened = true });
// Resolved: `/tmp/../tmp/.` is /tmp's +Pager, the one made above.
- const again = call(p, .{ .tag = 8, .op = .open, .node = pager, .omode = 2 }).reply.handle;
- try testing.expectEqual(Status.ok, call(p, .{ .tag = 8, .op = .write, .node = pager, .handle = again, .data = "/tmp/../tmp/.\n" }).reply.status);
- try testing.expectEqualStrings(want, call(p, .{ .tag = 9, .op = .read, .node = pager, .handle = again, .size = 64 }).bytes);
- _ = call(p, .{ .tag = 9, .op = .release, .node = pager, .handle = again });
+ const resolved = call(p, .{ .tag = 11, .op = .open, .node = pager, .omode = 2 }).reply.handle;
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 12, .op = .write, .node = pager, .handle = resolved, .data = "/tmp/../tmp/.\n" }).reply.status);
+ try testing.expectEqualStrings(want, call(p, .{ .tag = 13, .op = .read, .node = pager, .handle = resolved, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 14, .op = .release, .node = pager, .handle = resolved, .opened = true });
// One that may not be written is refused, permission denied.
try testing.expectEqual(E.PERM, wr(p, pager, "/proc/1\n").errno());
// One directory a write, absolute, and there.
try testing.expectEqual(E.INVAL, wr(p, pager, "/a\n/b\n").errno());
- // A second line on an open that asked already, as a mount sends two.
+ // One directory an open: one refused is not followed by another, as a
+ // mount sends two lines in two writes. (After one taken, what follows
+ // is the paged text: the test below.)
const twice = call(p, .{ .tag = 20, .op = .open, .node = pager, .omode = 2 }).reply.handle;
- try testing.expectEqual(Status.ok, call(p, .{ .tag = 21, .op = .write, .node = pager, .handle = twice, .data = "/tmp\n" }).reply.status);
+ try testing.expectEqual(E.INVAL, call(p, .{ .tag = 21, .op = .write, .node = pager, .handle = twice, .data = "relative\n" }).errno());
try testing.expectEqual(E.INVAL, call(p, .{ .tag = 22, .op = .write, .node = pager, .handle = twice, .data = "/tmp\n" }).errno());
_ = call(p, .{ .tag = 23, .op = .release, .node = pager, .handle = twice });
const relative = wr(p, pager, "relative\n");
@@ -4112,6 +4147,54 @@ test "pager answers its +Pager on the open that asked, whatever another client e
try testing.expectEqual(E.NOTDIR, wr(p, pager, "/etc/passwd\n").errno());
}
+test "what a /pager open writes after its directory is the text: plain in the body, its colours the pane's, clean, in by the read" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const pager = @intFromEnum(tree.TopFile.pager);
+ const asked = call(p, .{ .tag = 1, .op = .open, .node = pager, .omode = 2 }).reply.handle;
+ // The directory and the text's start in one write, escapes and all, a
+ // second line no directory; the rest in writes after it.
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = pager, .handle = asked, .data = "/tmp\n\x1b[31mred\x1b[m /a\n" }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 3, .op = .write, .node = pager, .handle = asked, .data = "\x1b]0;title\x07N\x08Next\r\n" }).reply.status);
+ const id = for (p.panes, 0..) |slot, i| {
+ const pn = slot orelse continue;
+ if (pn.file) |f| if (std.mem.eql(u8, f.path, "/tmp/+Pager")) break i;
+ } else return error.NoPager;
+ const pn = p.panes[id].?;
+ try testing.expectEqualStrings("", pn.file.?.content);
+ // The read that answers the pane finds the text in.
+ var line: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{pn.serial}), call(p, .{ .tag = 4, .op = .read, .node = pager, .handle = asked, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 5, .op = .release, .node = pager, .handle = asked, .opened = true });
+ const f = &pn.file.?;
+ try testing.expectEqualStrings("red /a\nNext\n", f.content);
+ try testing.expectEqualStrings("red /a\nNext\n", rd(p, Node.of(pn.serial, .body), 0, 64).bytes);
+ try testing.expectEqual(f.revision, f.saved_revision);
+ try testing.expectEqual(@as(usize, 2), f.pager_spans.len);
+ try testing.expectEqualStrings("red", f.content[f.pager_spans[0].start..f.pager_spans[0].end]);
+ try testing.expectEqual(pardes.Color{ .index = 1 }, f.pager_spans[0].style.fg);
+ try testing.expect(f.pager_spans[1].style.bold);
+ // One undo step back to the empty pane.
+ try testing.expect(f.history.undo_len > 0);
+ try testing.expectEqualStrings("", f.history.undo[f.history.undo_len - 1].content);
+ // An edit drops the colours (pager.zig's ponytail).
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 6, .op = .write, .node = Node.of(pn.serial, .body), .data = "more\n" }).reply.status);
+ try testing.expectEqual(@as(usize, 0), f.pager_spans.len);
+
+ // PagerColor off, and an open closed with no read: the same text, plain,
+ // in at the editor's next step (a close through a mount never waits).
+ p.settings.pager_color = false;
+ const shut = call(p, .{ .tag = 7, .op = .open, .node = pager, .omode = 1 }).reply.handle;
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 8, .op = .write, .node = pager, .handle = shut, .data = "/tmp\n" }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 9, .op = .write, .node = pager, .handle = shut, .data = "\x1b[1;32mgreen\x1b[m\n" }).reply.status);
+ _ = call(p, .{ .tag = 10, .op = .release, .node = pager, .handle = shut, .opened = true });
+ try testing.expectEqualStrings("", f.content);
+ fillClosedPagers(p);
+ try testing.expectEqualStrings("green\n", f.content);
+ try testing.expectEqual(@as(usize, 0), f.pager_spans.len);
+ try testing.expectEqual(f.revision, f.saved_revision);
+}
+
test "a command pane an exec open was answered is leased to it while it stays open: another client's command gets a pane of its own" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 40543ecf..5499a337 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -89,6 +89,7 @@ pub fn needsQuiet(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
.release => req.remove or holdsLines(p, req),
+ .read => holdsPage(p, req),
else => false,
};
}
@@ -99,8 +100,9 @@ pub fn changesPane(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
.open => req.node == @intFromEnum(TopFile.new),
- .release => req.remove or holdsLines(p, req),
- .lookup, .getattr, .read, .readdir => false,
+ .release => req.remove or holdsLines(p, req) or holdsPage(p, req),
+ .read => holdsPage(p, req),
+ .lookup, .getattr, .readdir => false,
};
}
@@ -138,6 +140,10 @@ pub const Open = struct {
/// `/listeners`, a ctl opened to read) frozen at the open, as
/// `/screen` is: reads in several chunks never splice two moments.
frozen: []const u8,
+ /// A /pager open past its directory line: the +Pager's serial, the
+ /// text after that line held in `pending` until its read puts it
+ /// in (ctl.fillPager), or its close hands it to the editor's step.
+ pager: u32,
} = .{ .snapshot = null },
/// What a write to a file of command lines left unfinished: a last line
/// with no newline yet, or an Edit block still open. A write through a
@@ -194,7 +200,7 @@ pub const Open = struct {
gpa.free(run.line);
},
.frozen => |bytes| gpa.free(bytes),
- .event, .pty_data, .ctl, .lines, .writes => {},
+ .event, .pty_data, .ctl, .lines, .writes, .pager => {},
}
o.pending.deinit(gpa);
if (o.replay) |r| gpa.free(r.bytes);
@@ -899,7 +905,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
pn.fs.event_reader = pn.fs.event_reader or reader;
},
.pty_data => pane_of.?.fs.pty_reader = true,
- .run, .ctl, .lines, .writes => {},
+ .run, .ctl, .lines, .writes, .pager => {},
}
return .{ .tag = req.tag, .handle = @intCast(i + 1) };
}
@@ -961,6 +967,12 @@ fn releaseHandle(p: *Pardes, req: Req) void {
if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial);
if (t == .top and t.top == .tag) cols.released(p, null);
const o = openOf(p, req) orelse return;
+ // A /pager open's text, not read for, goes in at the editor's next
+ // step (ctl.fillClosedPagers): this close need not wait for quiet.
+ if (o.what == .pager and o.pending.items.len > 0) {
+ p.fs.pages.append(p.gpa, .{ .serial = o.what.pager, .bytes = o.pending }) catch o.pending.deinit(p.gpa);
+ o.pending = .empty;
+ }
// The last line an open wrote with no newline after it runs now.
if (o.pending.items.len > 0) {
const rest = p.scratch.allocator().dupe(u8, o.pending.items) catch "";
@@ -990,7 +1002,7 @@ fn releaseHandle(p: *Pardes, req: Req) void {
.ctl => if (pn.fs.lock == req.handle) {
pn.fs.lock = null;
},
- .snapshot, .log, .lines, .writes, .frozen => {},
+ .snapshot, .log, .lines, .writes, .frozen, .pager => {},
};
o.deinit(p.gpa);
}
@@ -1014,6 +1026,8 @@ fn setattr(p: *Pardes, req: Req, target: Target) Reply {
}
fn read(p: *Pardes, req: Req, target: Target) Reply {
+ // A /pager open's text goes in before its read answers the pane.
+ if (openOf(p, req)) |o| if (o.what == .pager) ctl.fillPager(p, o.what.pager, &o.pending) catch return Reply.fail(req.tag, E.NOMEM);
// A listing reads what it was at the open.
if (openOf(p, req)) |o| if (o.what == .frozen) {
const bytes = o.what.frozen;
@@ -1158,7 +1172,7 @@ fn linesOpen(target: Target, req: Req) bool {
/// A release that will run a line its open held back.
fn holdsLines(p: *Pardes, req: Req) bool {
const o = openOf(p, req) orelse return false;
- return o.pending.items.len > 0;
+ return o.pending.items.len > 0 and o.what != .pager;
}
/// A line held back longer than this is refused rather than kept growing.
@@ -1173,7 +1187,39 @@ fn ctlFile(target: Target) bool {
};
}
+/// /pager on an open: its first line names the directory and runs as any
+/// line does; what follows it, in that write and the open's later ones, is
+/// a program's output, escapes and all, held whole and never run as lines.
fn write(p: *Pardes, req: Req, target: Target) Reply {
+ const o = (if (target == .top and target.top == .pager) openOf(p, req) else null) orelse return writeLines(p, req, target);
+ if (o.what == .pager) {
+ if (o.pending.items.len + req.data.len > pager_cap) return failText(req.tag, E.NOSPC, "pager: no space: the text is over a file's limit");
+ o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+ }
+ const nl = std.mem.indexOfScalar(u8, req.data, '\n') orelse return writeLines(p, req, target);
+ var head = req;
+ head.data = req.data[0 .. nl + 1];
+ const reply = writeLines(p, head, target);
+ if (reply.status != .ok or o.results.len == 0) return reply;
+ o.what = .{ .pager = o.results.list[0] };
+ o.pending.clearRetainingCapacity();
+ o.pending.appendSlice(p.gpa, req.data[nl + 1 ..]) catch return Reply.fail(req.tag, E.NOMEM);
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// The most a /pager open holds: a file's limit, and `pardes -`'s note
+/// that its stdin was cut there.
+const pager_cap = @import("../memory.zig").limits.max_file_bytes + 4096;
+
+/// A /pager open holding text: its read puts it in (ctl.fillPager), its
+/// close hands it to the editor's step.
+pub fn holdsPage(p: *Pardes, req: Req) bool {
+ const o = openOf(p, req) orelse return false;
+ return o.what == .pager and o.pending.items.len > 0;
+}
+
+fn writeLines(p: *Pardes, req: Req, target: Target) Reply {
// A command line holds no control character but a tab: the whole write
// is refused at once, not held to fail unseen at the close.
if (resultsFile(target) or ctlFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control);
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 } } }});
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index c6850451..5d9f8606 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -11,6 +11,7 @@ pub const ctlfs = @import("ninep/tree.zig");
pub const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
pub const panes = @import("panes.zig");
+pub const pager = @import("pager.zig");
const locations = @import("locations.zig");
pub const tagline = @import("tagline.zig");
pub const exec = @import("exec.zig");
@@ -496,6 +497,7 @@ test {
_ = @import("recent.zig");
_ = @import("Presentation.zig");
_ = @import("animation.zig");
+ _ = @import("pager.zig");
}
pub const Builtin = builtins.registry.Builtin();
@@ -4221,7 +4223,8 @@ pub const Options = struct {
file: ?[]const u8 = null,
file_line: usize = 0,
missing: ?struct { word: []const u8, dir: []const u8 } = null,
- /// `pardes -`: stdin, its escapes stripped, for a +Pager pane at boot.
+ /// `pardes -`: stdin as it came, for a +Pager pane at boot (pager.zig
+ /// parses it).
stdin_text: ?[]const u8 = null,
tty_toggle: u21 = config.tty_toggle_default,
/// load a dump of another instance instead of spawning shells (acme -l)
@@ -4638,7 +4641,9 @@ pub const Pardes = struct {
p.oneColumn();
},
.stdin => {
- const content = try gpa.dupe(u8, opts.stdin_text.?);
+ const page = try pager.parse(gpa, opts.stdin_text.?, p.settings.pager_color);
+ errdefer gpa.free(page.spans);
+ const content = page.text;
errdefer gpa.free(content);
const dir = p.sessionDir();
const shown = try panes.Output.open(p, 0, dir, .{ .cmd = .New }, "", content);
@@ -4647,6 +4652,7 @@ pub const Pardes = struct {
gpa.free(shown.file.?.path);
shown.file.?.path = named;
shown.file.?.pager_diff = panes.File.looksLikeDiff(content[0..@min(content.len, 64 * 1024)]);
+ shown.file.?.pager_spans = page.spans;
p.oneColumn();
},
.embedded => {
@@ -5748,6 +5754,8 @@ pub const Pardes = struct {
if (v.now) |clock| p.advance(clock(h.ctx));
// What a 9P open wrote in a row goes in once the writes pause.
ctlfs.pane.flushPausedBatch(p);
+ // ...and what a /pager open was written before its close.
+ ctlfs.ctl.fillClosedPagers(p);
while (p.nextQueued()) |ev| p.update(ev);
while (p.nextEffect()) |e| p.perform(e);
turn.settle();
diff --git a/test/fs.py b/test/fs.py
index 8eed2e29..33abb835 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -1529,6 +1529,26 @@ def test(binary, quic=False):
log_now = client.read('/log').decode()
assert f'\nnew {made_now} {root}/+Pager\n' in '\n' + log_now, log_now[-400:]
assert f'\nrename {made_now} ' not in '\n' + log_now
+ # Its colours are the pane's, drawn and never read: the body is
+ # the plain text, and the screen shows `red` in the theme's red
+ # and `rgb` in the colour given, the rest in the pane's ink.
+ fed = subprocess.run([binary, '-'], cwd=root, env=env,
+ input=b'\x1b]0;title\x07\x1b[31mred\x1b[m \x1b[38;2;1;2;3mrgb\x1b[m plain\n',
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
+ assert fed.returncode == 0, fed.stderr
+ assert client.read(f'/pane/{made_now}/body') == b'red rgb plain\n'
+ screen = client.screen()
+ for row in range(screen['rows']):
+ cells = screen['cells'][row * screen['cols']:(row + 1) * screen['cols']]
+ text = ''.join(cell[0] for cell in cells)
+ at = text.find('red rgb plain')
+ if at >= 0:
+ break
+ else:
+ raise AssertionError(('the paged text is not on the screen', screen))
+ ink = [screen['styles'][cells[at + i][1]]['fg'] for i in (0, 4, 8)]
+ assert ink[1] == {'rgb': [1, 2, 3]}, ink
+ assert ink[0] != ink[2] and ink[0] != ink[1], ink
# The directory's one +Pager: the next paged text refills it.
fed = subprocess.run([binary, '-'], cwd=root, env=env, input=b'again\r\nprogress 10%\rprogress 100%\n',
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10)
diff --git a/test/selfmount.py b/test/selfmount.py
index ddfb77dc..57dfffa8 100644
--- a/test/selfmount.py
+++ b/test/selfmount.py
@@ -182,6 +182,20 @@ def run(binary):
log = client.read('/log').decode()
check(log.count('over 1 MiB') == 1 and 'unknown control message' not in log and 'after-long' in log,
'a 1.1 MiB line is refused once, its tail never a line, the next line runs')
+ # `pardes -` by hand through the mount: the directory, then a
+ # program's output, escapes and all, on one open of /pager; the
+ # +Pager reads back the plain text.
+ got = run(f"{{ pwd; printf '\\033[31mred\\033[m\\n'; }} > {mount}/pager\n".encode())
+ rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in client.read('/index').decode().splitlines()]
+ paged = [row[0] for row in rows if len(row) == 4 and row[3].endswith('/+Pager')]
+ check(got == '0' and len(paged) == 1, f'a +Pager made through the mount ({got!r}, {paged!r})')
+ if paged:
+ # The text goes in at the open's close, and a mount's close
+ # (FUSE's release) comes after close(2) has returned.
+ deadline = time.monotonic() + 5
+ while (said := client.read(f'/pane/{paged[0]}/body')) != b'red\n' and time.monotonic() < deadline:
+ time.sleep(.05)
+ check(said == b'red\n', f'its body is the plain text ({said!r})')
# Forty event reads held through the mount, a follower each, and
# the mount still answers the rest: 9ns keeps workers past what
# the editor holds (128), so `cat layout` is not queued behind
diff --git a/test/snapshots/pagercolor.golden b/test/snapshots/pagercolor.golden
new file mode 100644
index 00000000..811d3d96
--- /dev/null
+++ b/test/snapshots/pagercolor.golden
@@ -0,0 +1,120 @@
+== snap pager grid=100x50 cursor=4,5
+|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+|$ /tmp/pardes-snap/pagercolor/cwd Tty+bash Save Mode Filter Collapse Del
+| $ printf '\033]0;title\007\033[31mred\033[m \033[1;32mbold green\033[m \033[38;2;200;100;0mrgb\033
+| [m \033[4mul\033[m N\bNA\bAM\bME\bE _\bu_\bs plain\n' | $(readlink /proc/$PPID/exe) -
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/pagercolor/cwd/+New Save Tty Collapse Del
+| 1
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/pagercolor/cwd/+Pager Save Tty Collapse Del
+| 1 red bold green rgb ul NAME us plain
+| 2
+== style pager-colours grid=100x50
+|0: 0-99 #b2c3b8,#1d2824,
+|1: 0-1 d,#887455, 2-2 d,#314738, 3-99 #becfc4,#314738,
+|2: 0-1 #000000,#add38a, 2-2 #000000,#314738, 3-34 #becfc4,#314738, 35-43 #dfbce5,#314738, 44-99 #becfc4,#314738,
+|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
+|4: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
+|5: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
+|6: 0-0 d,#608269, 1-99 d,#0d1410,
+|7: 0-0 d,#608269, 1-99 d,#0d1410,
+|8: 0-0 d,#608269, 1-99 d,#0d1410,
+|9: 0-0 d,#608269, 1-99 d,#0d1410,
+|10: 0-0 d,#608269, 1-99 d,#0d1410,
+|11: 0-0 d,#608269, 1-99 d,#0d1410,
+|12: 0-0 d,#608269, 1-99 d,#0d1410,
+|13: 0-0 d,#608269, 1-99 d,#0d1410,
+|14: 0-0 d,#608269, 1-99 d,#0d1410,
+|15: 0-0 d,#608269, 1-99 d,#0d1410,
+|16: 0-0 d,#608269, 1-99 d,#0d1410,
+|17: 0-0 d,#608269, 1-99 d,#0d1410,
+|18: 0-0 d,#608269, 1-99 d,#0d1410,
+|19: 0-0 d,#608269, 1-99 d,#0d1410,
+|20: 0-0 d,#608269, 1-99 d,#0d1410,
+|21: 0-0 d,#608269, 1-99 d,#0d1410,
+|22: 0-0 d,#608269, 1-99 d,#0d1410,
+|23: 0-0 d,#608269, 1-99 d,#0d1410,
+|24: 0-0 d,#608269, 1-99 d,#0d1410,
+|25: 0-0 d,#608269, 1-99 d,#0d1410,
+|26: 0-0 d,#608269, 1-99 d,#0d1410,
+|27: 0-0 d,#608269, 1-99 d,#0d1410,
+|28: 0-0 d,#608269, 1-99 d,#0d1410,
+|29: 0-0 d,#608269, 1-99 d,#0d1410,
+|30: 0-0 d,#608269, 1-99 d,#0d1410,
+|31: 0-0 d,#608269, 1-99 d,#0d1410,
+|32: 0-0 d,#608269, 1-99 d,#0d1410,
+|33: 0-0 d,#608269, 1-99 d,#0d1410,
+|34: 0-1 d,#496656, 2-2 d,#1d2824, 3-34 #b2c3b8,#1d2824, 35-39 #d3b0d9,#1d2824, 40-99 #b2c3b8,#1d2824,
+|35: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410,
+|36: 0-0 d,#608269, 1-99 d,#0d1410,
+|37: 0-0 d,#608269, 1-99 d,#0d1410,
+|38: 0-0 d,#608269, 1-99 d,#0d1410,
+|39: 0-0 d,#608269, 1-99 d,#0d1410,
+|40: 0-0 d,#608269, 1-99 d,#0d1410,
+|41: 0-0 d,#608269, 1-99 d,#0d1410,
+|42: 0-1 d,#496656, 2-2 d,#1d2824, 3-34 #b2c3b8,#1d2824, 35-41 #d3b0d9,#1d2824, 42-99 #b2c3b8,#1d2824,
+|43: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-10 #f1a39b,#0d1410, 11-20 #b6ce94,#0d1410,b 21-21 #b6ce94,#0d1410, 22-25 #c86400,#0d1410, 26-27 #e5eee5,#0d1410,u 28-28 #e5eee5,#0d1410, 29-32 #e5eee5,#0d1410,b 33-33 #e5eee5,#0d1410, 34-35 #e5eee5,#0d1410,u 36-99 #e5eee5,#0d1410,
+|44: 0-0 d,#608269, 1-4 d,#0d1410, 5-99 #59675c,#0d1410,
+|45: 0-0 d,#608269, 1-99 d,#0d1410,
+|46: 0-0 d,#608269, 1-99 d,#0d1410,
+|47: 0-0 d,#608269, 1-99 d,#0d1410,
+|48: 0-0 d,#608269, 1-99 d,#0d1410,
+|49: 0-0 d,#608269, 1-99 d,#0d1410,
+== snap pager-diff grid=100x50 cursor=4,8
+|5: $ printf '\033[33mcommit 0123\033[33mabc\033[m\n\033[1mdiff --git a/x b/x\033[m\n\033[1m--- a/x\03
+|6: 3[m\n\033[1m+++ b/x\033[m\n\033[36m@@ -1 +1 @@\033[m\n\033[31m-old\033[m\n\033[32m+new\033[m\n' |
+|7: $(readlink /proc/$PPID/exe) -
+|8: $
+|43: 1 commit 0123abc
+|44: 2 diff --git a/x b/x
+|45: 3 --- a/x
+|46: 4 +++ b/x
+|47: 5 @@ -1 +1 @@
+|48: 6 -old
+|49: 7 +new
+== style pager-diff-colours grid=100x50
+|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
+|7: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
+|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
+|43: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #e9c680,#0d1410,
+|44: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-24 #b9f480,#0d1410,b 25-99 #b9f480,#0d1410,
+|45: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-13 #b9f480,#0d1410,b 14-99 #b9f480,#0d1410,
+|46: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-13 #b9f480,#0d1410,b 14-99 #b9f480,#0d1410,
+|47: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-99 #b6c59e,#0d1410,
+|48: 0-0 d,#608269, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-7 #f1a39b,#302b25,b 8-99 #f8c082,#302b25,
+|49: 0-0 d,#152019, 1-4 d,#0d1410, 5-6 #59675c,#0d1410, 7-7 #b6ce94,#263025,b 8-99 #b8d88a,#263025,
diff --git a/test/snapshots/pagercolor.snap b/test/snapshots/pagercolor.snap
new file mode 100644
index 00000000..650792b3
--- /dev/null
+++ b/test/snapshots/pagercolor.snap
@@ -0,0 +1,24 @@
+# `pardes -` keeps a program's colours (PagerColor, on by default): its SGR
+# becomes the +Pager's own colours over the plain text (red through the
+# theme's palette, truecolor as given, bold and underline, a man page's
+# overstrike), and every other escape (the OSC title) is dropped. The
+# needles waited for are split by escapes in the typed line, so only the
+# paged text holds them.
+start 50 100
+wait 8000 $
+stable 700 20000
+text printf '\033]0;title\007\033[31mred\033[m \033[1;32mbold green\033[m \033[38;2;200;100;0mrgb\033[m \033[4mul\033[m N\bNA\bAM\bME\bE _\bu_\bs plain\n' | $(readlink /proc/$PPID/exe) -
+key enter
+wait 20000 red bold green
+stable 700 20000
+snap pager
+snapstyle pager-colours
+# A coloured diff: the rows pardes tints as added or removed are drawn as
+# its own diff, and git's colours stay on the rest (the bold header, the
+# cyan hunk line, the yellow commit line).
+text printf '\033[33mcommit 0123\033[33mabc\033[m\n\033[1mdiff --git a/x b/x\033[m\n\033[1m--- a/x\033[m\n\033[1m+++ b/x\033[m\n\033[36m@@ -1 +1 @@\033[m\n\033[31m-old\033[m\n\033[32m+new\033[m\n' | $(readlink /proc/$PPID/exe) -
+key enter
+wait 20000 0123abc
+stable 700 20000
+snap pager-diff
+snapstyle pager-diff-colours