diff options
| -rw-r--r-- | src/9p_io.zig | 28 | ||||
| -rw-r--r-- | src/File.zig | 40 | ||||
| -rw-r--r-- | src/config.zig | 8 | ||||
| -rw-r--r-- | src/fs.zig | 7 | ||||
| -rw-r--r-- | src/main.zig | 129 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 103 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 58 | ||||
| -rw-r--r-- | src/pager.zig | 373 | ||||
| -rw-r--r-- | src/pardes.zig | 12 | ||||
| -rw-r--r-- | test/fs.py | 20 | ||||
| -rw-r--r-- | test/selfmount.py | 14 | ||||
| -rw-r--r-- | test/snapshots/pagercolor.golden | 120 | ||||
| -rw-r--r-- | test/snapshots/pagercolor.snap | 24 |
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", @@ -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(); @@ -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 |
