diff options
| -rw-r--r-- | build.zig | 16 | ||||
| -rw-r--r-- | build.zig.zon | 6 | ||||
| -rw-r--r-- | src/CHANGELOG.md | 53 | ||||
| -rw-r--r-- | src/Output.zig | 5 | ||||
| -rw-r--r-- | src/config.zig | 19 | ||||
| -rw-r--r-- | src/crash.zig | 2 | ||||
| -rw-r--r-- | src/detached/server.zig | 13 | ||||
| -rw-r--r-- | src/exec.zig | 30 | ||||
| -rw-r--r-- | src/fs.zig | 8 | ||||
| -rw-r--r-- | src/host_io.zig | 95 | ||||
| -rw-r--r-- | src/look.zig | 134 | ||||
| -rw-r--r-- | src/main.zig | 132 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 4 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 133 | ||||
| -rw-r--r-- | src/ninep/events.zig | 79 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 24 | ||||
| -rw-r--r-- | src/panes.zig | 4 | ||||
| -rw-r--r-- | src/pardes.zig | 16 | ||||
| -rw-r--r-- | src/pdf.zig | 128 | ||||
| -rw-r--r-- | src/pdf_bridge.c | 76 | ||||
| -rw-r--r-- | src/pdf_bridge.h | 19 | ||||
| -rw-r--r-- | src/pdf_view.zig | 175 | ||||
| -rw-r--r-- | src/tagline.zig | 2 | ||||
| -rw-r--r-- | test/fs.py | 90 | ||||
| -rw-r--r-- | test/pdf.zig | 168 | ||||
| -rw-r--r-- | test/pdf_scroll_bench.zig | 106 | ||||
| -rw-r--r-- | test/selfmount.py | 10 | ||||
| -rw-r--r-- | test/snapshots/nested-optout.golden | 60 |
28 files changed, 1418 insertions, 189 deletions
@@ -6,6 +6,12 @@ const grammar_manifest = @import("src/grammar_manifest.zig"); /// The package manifest, imported UNTYPED on purpose — see the `version` /// option in `shellOptions` for why that word is load-bearing. const zon = @import("build.zig.zon"); +/// Releases are numbered 0.N; the zon holds "0.N.0" because it must be semver, +/// and what a user sees drops that ".0". +const display_version = if (std.mem.endsWith(u8, zon.version, ".0") and std.mem.count(u8, zon.version, ".") == 2) + zon.version[0 .. zon.version.len - 2] +else + zon.version; pub const Platform = enum { tty, gui, web, macos, esp32p4 }; @@ -179,7 +185,11 @@ pub fn build(b: *std.Build) void { if (!is_web and (is_web_target or target.result.os.tag == .emscripten)) return failBuild(b, web_step, "wasm browser targets require -Dplatform=web"); if (platform == .web and dump_path == null) return failBuild(b, web_step, "-Dplatform=web requires -Ddump=<dump.zon> (the browser has no ptys; state replays from an embedded dump)"); if (enable_mupdf and is_web) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends"); - if (is_esp32p4 and !is_esp32p4_target) return failBuild(b, web_step, "-Dplatform=esp32p4 requires -Dtarget=riscv32-freestanding (ESP32-P4 firmware)"); + // The board's target is pinned (`target` above), so what is asked of + // -Dtarget is checked, not what was resolved: another arch is refused, + // not silently built for the board anyway. + const requested_board = requested_target.result.cpu.arch == .riscv32 and requested_target.result.os.tag == .freestanding; + if (is_esp32p4 and !requested_board) return failBuild(b, web_step, "-Dplatform=esp32p4 requires -Dtarget=riscv32-freestanding (ESP32-P4 firmware)"); if (enable_mupdf and is_esp32p4) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends"); if (is_esp32p4 and requested_grammars != null and requested_grammars.? != .disabled) return failBuild(b, web_step, "-Dplatform=esp32p4 has no tree-sitter: the grammars' parse tables are megabytes and the flash partition is 1.5 MiB (-Dtree-sitter=disabled)"); @@ -1146,7 +1156,7 @@ pub fn build(b: *std.Build) void { plist.addFileArg(b.path("src/macos/Info.plist")); const version_plist = b.addSystemCommand(&.{ - "plutil", "-replace", "CFBundleShortVersionString", "-string", zon.version, "-o", + "plutil", "-replace", "CFBundleShortVersionString", "-string", display_version, "-o", }); const version_plist_out = version_plist.addOutputFileArg("Info.plist"); version_plist.addFileArg(plist_out); @@ -1873,7 +1883,7 @@ fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build. o.addOption(bool, "workspace_tag", cfg.workspace_tag orelse (platform != .macos)); o.addOption(bool, "theme_animation", cfg.theme_animation orelse (platform != .esp32p4)); o.addOption(bool, "embed_sources", cfg.embed_sources); - o.addOption([]const u8, "version", zon.version); + o.addOption([]const u8, "version", display_version); // ...and the commit it was built from, when there is one and the build // is stamped at all (see `stamp_commit`). Read at configure time. o.addOption(?[]const u8, "commit", cfg.commit); diff --git a/build.zig.zon b/build.zig.zon index 4df012bc..15d2e9aa 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .pardes, - .version = "0.0.15", + .version = "0.16.0", .dependencies = .{ // 9P sessions and transports live in the published cloud9 package, so // the manifest pins the build: `zig build` fetches this commit into @@ -9,8 +9,8 @@ // read-only HTTPS URL is what a manifest can carry. Re-pin with // `zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`. .cloud9 = .{ - .url = "git+https://git.sr.ht/~gbrls/cloud9#705be665742fa0332585a4321640b5e2245610cb", - .hash = "cloud9-0.1.0-yt86qt8NHgDuS2WNp0TgW03h-eVIgWmkznn0t0nMQKbr", + .url = "git+https://git.sr.ht/~gbrls/cloud9#a03d1649108510cda5951e918e561d7cae43d6b0", + .hash = "cloud9-0.1.0-yt86qiISHgBgvO5D4UOz0pZnWrLmL337v38Z8ROAeLG0", }, // Pinned to the exact libvaxis commit ghostty is built against, so it // is compatible with ghostty's uucode configuration (the one we share diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 9406ceab..24b0c9fb 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,6 +1,29 @@ # Changelog -## 0.0.15 +## 0.16 + +- Releases are numbered 0.N from here on; this changelog's earlier 0.0.N + headings read 0.N. +- `pardes -` reads stdin into a `+Pager` pane, and a terminal's `PAGER` and + `GIT_PAGER` are `pardes -` unless your environment sets them: `git log` + and `man` open as panes, and a paging command through `pty/run` no longer + waits. Command panes page through `cat`. `Pager off` keeps your own pager. +- A command run from a pane gets `$winid`, that pane's serial, as acme's do. +- A PDF page taller than a raster is drawn at the size it is shown, in bands + around the screen, instead of being squeezed into 4096 rows and enlarged: + tall pages are sharp, and memory stays bounded. +- A diff line's look, whether clicked, written to `look` or written back + through `event`, goes through one expansion; a blank context line works. +- `log` stats 0 and 9ns opens files of length 0 as streams, so `cat` follows + the log through a mount. +- A second `--detach=NAME` while NAME runs says so. `/index` escapes control + bytes. `file:0:0` is refused. An event reader of a closed pane reads EOF. + `+Unsaved` lands in a directory that exists. `pardes NEW` refuses a + directory it may not write. +- Docs: a "Setting up your environment" chapter (9ns, the editor and pager, + yazi, shells, language servers, fonts, plan9port) after the guide. + +## 0.15 - The documentation is one Typst corpus in `docs/typ/`: a guide (a short walkthrough: modes, buttons, tags, the active column, looks, terminals, @@ -15,7 +38,7 @@ filled, keys as one pill per press (`Ctrl+w`, `g›g`), and its pairs as tables. -## 0.0.14 +## 0.14 - A pane's `tagexec` reaches an event reader holding the pane as an `x` record, as a click in its tag does. @@ -37,7 +60,7 @@ list's), `event` takes one reader, and bash's `printf` writes a mounted ctl one line at a time. -## 0.0.13 +## 0.13 - A one-page A4 cheatsheet, built from Typst sources in `docs/typ/` (`zig build cheatsheet`); a test fails when it names a word, key or tag that no longer exists. - A look on a diff line is the look of the `path:line` it names, exactly as @@ -46,7 +69,7 @@ hand-selected path would, and the diff's own path finding and its messages are gone. -## 0.0.12 +## 0.12 - A selection deep in a very long line no longer slows every frame: a 5 MB line with the selection at its end redraws in milliseconds, not seconds. @@ -71,7 +94,7 @@ program as its buttons 2 and 3. B1 and the wheel stay the program's, and Shift keeps them pardes's. -## 0.0.11 +## 0.11 - A look on a diff names a file as the repository does: git's `a/`/`b/` (or mnemonic `c/`, `i/`, `w/`) is dropped only where both paths of the @@ -79,7 +102,7 @@ plain `diff -u`'s names are as written, and a missing file is said by that name (`Look: src/x.zig: no such file here`). -## 0.0.10 +## 0.10 - A 9P write never moves dot's end: scrolling written text into view moves only the view, and dot shifts as acme's textinsert shifts it, so a `sel` @@ -96,7 +119,7 @@ - A terminal's word looks share one `+Search` per directory. `Save path` logs its `save`. Restore logs the absolute path it read. -## 0.0.9 +## 0.9 - A terminal's program that tracks the mouse gets its clicks, drags and motion as well as its wheel, in the format it asked for; hold Shift for @@ -119,7 +142,7 @@ bytes) keeps its cut last line for the next write, so `seq … > exec` runs every line once. -## 0.0.8 +## 0.8 - `~` and `~/` mean the home directory wherever a path is taken: look (`~/x:12`, `~/x:/re/`), `name`, `Save`, `ThemeFile`, DumpDir, Restore. @@ -149,7 +172,7 @@ is refused. `Kill X` with nothing running is a message, not a failure. An event write-back needs no trailing newline. -## 0.0.7 +## 0.7 - `lapis` draws its cells unblended and each plaque pixel once: about a third less GPU time a frame at 4K, pixel for pixel the same. `lapis_plain` @@ -175,7 +198,7 @@ - `Lspwhy` explains the last language-server query, whatever its kind. - Bulk body writes over 9P are about 15x faster per 60 KB write. -## 0.0.6 +## 0.6 - A middle or right sweep in a column tag or the workspace tag runs or looks up the swept text on release, highlighted while it is held, as in a pane's @@ -201,7 +224,7 @@ - The GUI always embeds its shader sources; the prebuilt GLSL copies are gone. `lspprobe` is `zig build lspbench -- probe`. -## 0.0.5 +## 0.5 - Over a slow link the terminal build keeps at most one frame in flight: it asks the terminal for a status report after each frame and draws the @@ -225,7 +248,7 @@ - The docs and the 9P skill are shorter, with one home per fact. `zig build monkey-gui` runs the GUI monkey. -## 0.0.4 +## 0.4 - Drive the editor over 9P the way acme is driven. Panes take sam addresses (`/re/`, `#n`, `$-1`, `0/re/`), `L:C` and inclusive `L:C-L2:C2` @@ -343,7 +366,7 @@ with `-Dembed-sources=true`. The tree code moved from `src/fs.zig` to `src/ninep/`. -## 0.0.3 +## 0.3 - Add pinned tree-sitter declaration context, with optional compact tagline styling and separators between discontinuous source lines. @@ -366,7 +389,7 @@ - Detached sessions now use protocol version 6. Update the server and attached clients together. -## 0.0.2 +## 0.2 - A panic is written down somewhere it survives. Every crash this program has ever had went to stderr and nowhere else, and stderr is the one place it @@ -532,7 +555,7 @@ curated three the floor refuses 12, 16 and 7 of 256 keys, where a WCAG body-text 4.5 would refuse 61, 154 and 91 and flatten the palette. -## 0.0.1 +## 0.1 - Pane taglines sit where they do in the SDL window. The macOS shell had its own copy of the band geometry and it only ever centred the shorter band in its diff --git a/src/Output.zig b/src/Output.zig index 4aa0c021..1160c455 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -656,7 +656,10 @@ pub fn openUnsaved(p: *Pardes, id: usize, content: []u8) void { return; } const pane = p.panes[id] orelse return p.gpa.free(content); - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const own = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + // Its directory gone (or never there, a name not yet saved): the list + // goes in the session's, where a look from it can still resolve. + const dir = if (comptime pardes.hosted) (if (pardes.filesystem.localPath(own)) |local| (if (exec.isDirectory(local)) own else p.sessionDir()) else own) else own; const free = p.freeSlot() orelse return p.gpa.free(content); _ = open(p, free, dir, .unsaved, "", content) catch return; exec.placeNew(p, id, id, free, .doc); diff --git a/src/config.zig b/src/config.zig index 7908774b..5a9cf070 100644 --- a/src/config.zig +++ b/src/config.zig @@ -17,7 +17,9 @@ pub const Chord = struct { shift: bool = false, }; -// Leader paths apply in body normal mode, not tags or raw terminals. +// Leader paths apply wherever normal mode reads keys: a body's, a tag's +// (column and workspace tags too) and a PDF's, never a raw terminal's +// (normal.zig takes `.leader` for every target). pub const leader: []const Chord = &.{.{ .cp = ' ' }}; pub const leader_help: u8 = '?'; @@ -79,6 +81,7 @@ pub const leader_path = paths: { .Placement = "cp", .BootShell = null, .LookWord = null, + .Pager = null, .Debug = "td", .Msg = null, .Colors = "tc", @@ -752,13 +755,18 @@ pub const Runtime = struct { /// next place in that pane, wrapping; `list`, what pardes did before, /// lists every place in a +Search pane (look.zig, lookWord). look_word: LookWord = .search, + /// What a terminal's shell pages through (host_io.ChildEnv): `pardes`, + /// 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, pub const ShaderAnimation = enum { off, on, always }; pub const Placement = enum { acme, pardes }; pub const BootShell = enum { keep, replace }; pub const LookWord = enum { search, list }; + pub const Pager = enum { pardes, off }; /// A setting that is one of a few words, each an enum field of Runtime. - pub const Choice = enum { placement, boot_shell, look_word }; + pub const Choice = enum { placement, boot_shell, look_word, pager }; /// shadow: a soft drop shadow. rim: a light hairline along the top of /// the focused pane's tag rows. auto: a shadow on a light page; on a @@ -998,7 +1006,7 @@ pub const Runtime = struct { .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve }, .doc = "How long, in milliseconds, a message takes to dissolve." }, .{ .word = "DumpDir", .action = .dump_dir, .doc = "The directory Dump writes to and a relative Restore reads from; bare, the default." }, .{ .word = "Theme", .action = .theme, .doc = "Switch to the named theme." }, - .{ .word = "Shell", .action = .shell, .doc = "The shell new terminals run; bare, the login shell." }, + .{ .word = "Shell", .action = .shell, .doc = "The shell new terminals run; bare, $SHELL if it is executable, else /bin/sh." }, .{ .word = "Font", .action = .font, .availability = .font_picker, .doc = "The body font, by name or path, and its size." }, .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker, .doc = "Scale the tag font, a percentage." }, .{ .word = "WindowOpacity", .action = .window_opacity, .availability = .window_opacity, .doc = "The window's opacity, a percentage." }, @@ -1023,6 +1031,7 @@ pub const Runtime = struct { .{ .word = "Placement", .action = .{ .choice = .placement }, .doc = "Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules." }, .{ .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." }, }; pub fn find(name: []const u8) ?Setting { @@ -1250,7 +1259,7 @@ pub const Runtime = struct { .grip_width => "50-300 (a percentage)", .tagline_size => std.fmt.comptimePrint("{d}-{d} (a percentage)", .{ tagline_percent_min, tagline_percent_max }), .message_ms => std.fmt.comptimePrint("0-{d} (milliseconds)", .{message_ms_max}), - .shell => "a path of at most 255 bytes; bare, the login shell", + .shell => "a path of at most 255 bytes; bare, $SHELL, else /bin/sh", .dump_dir => std.fmt.comptimePrint("a path of at most {d} bytes; bare, the default", .{limits.host_path_cap}), .shader => "a Shadertoy file's path, or off", .font => "a name or path, :8 to :72 after it (Mono:14)", @@ -1697,6 +1706,7 @@ pub const Runtime = struct { .placement => "pardes", .boot_shell => "replace", .look_word => "list", + .pager => "off", }, .lift => "rim", .motion => "playful", @@ -1766,6 +1776,7 @@ pub const Runtime = struct { .placement => "Placement pardes\n", .boot_shell => "BootShell replace\n", .look_word => "LookWord list\n", + .pager => "Pager off\n", }, .lift => "Lift rim\n", .motion => "Motion playful\n", diff --git a/src/crash.zig b/src/crash.zig index 99822aad..34dc1b3d 100644 --- a/src/crash.zig +++ b/src/crash.zig @@ -47,7 +47,7 @@ pub fn setDir(path: []const u8) void { /// Built at comptime out of the two halves main.zig's `--version` prints, and /// for the same reason: there is nothing here to format at runtime. A build -/// from a tarball says `pardes 0.0.1` rather than inventing a revision. +/// from a tarball says `pardes 0.1` rather than inventing a revision. const build_id = if (pardes.commit) |c| "pardes " ++ pardes.version ++ " (" ++ c ++ ")" else diff --git a/src/detached/server.zig b/src/detached/server.zig index 78b85836..588912bb 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -1238,6 +1238,17 @@ pub const detached_cols = 160; pub const detached_rows = 50; pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void { + // A name a live session holds: said, exit 1, before anything is made + // (no error trace, no second session racing the first for the socket). + if (comptime supported) { + var path_buf: [path_max]u8 = undefined; + if (sessionPath(&path_buf, name)) |path| if (ninep_io.statNoFollow(path) != null and alive(path)) { + var said: [path_max + 64]u8 = undefined; + const text = std.fmt.bufPrint(&said, "pardes: a session named {s} is already running\n", .{name}) catch "pardes: a session of that name is already running\n"; + std.Io.File.stderr().writeStreamingAll(init.io, text) catch {}; + std.process.exit(1); + }; + } const gpa = init.gpa; const allocs = pardes.memory.init(gpa); defer pardes.memory.deinit(); @@ -1282,7 +1293,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void try session.initAsync(); if (!session.listen(name)) { try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: could not bind a detached session socket\n"); - return error.NoSocket; + std.process.exit(1); } session.ninep = ninep_io.listen(init.io, gpa, core, opts.ninep_name, name, opts.ninep_tcp, opts.ninep_quic); diff --git a/src/exec.zig b/src/exec.zig index 104e3b4d..47484105 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -176,21 +176,14 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand // the code's, the prefix never one of them. var word_from: usize = 0; if (clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1 and row >= 0) diff: { - const lines = look.diffLines(p, pane) orelse break :diff; - const at: usize = @intCast(row); - if (at >= lines.len) break :diff; - const line = lines[at]; var address_buf: [4096 + 32]u8 = undefined; - const address = look.diffAddress(pane, lines, at, &address_buf) orelse break :diff; - const target = @import("diff.zig").targetAt(lines, at, pane.file == null).?; - const on_prefix = col == 0 and line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null; - switch (target.kind) { - .context, .added, .removed => if (!on_prefix) { - word_from = @intFromBool(line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null); - break :diff; - }, - else => {}, - } + const at: usize = @intCast(row); + const click = look.diffClick(p, pane, at, @intCast(@max(0, col)), &address_buf) orelse break :diff; + const address = click.address orelse { + word_from = click.word_from; + break :diff; + }; + const line = (look.diffLines(p, pane) orelse break :diff)[at]; result.text = p.scratch.allocator().dupe(u8, address) catch break :diff; const hi = std.math.cast(i32, line.len) orelse return result; if (hi == 0) return result; @@ -799,6 +792,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { if (panes.terminal.onAlternateScreen(pane)) panes.terminal.feedOutput(p, pane, "\x1b[?1049l"); panes.terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m"); echoCommand(p, pane, line); + pane.command_from = winid(p, src); p.emitSpawn(id, pane.serial, pane.cwdSlice()); noteRun(p, pane, "run", line); return id; @@ -811,12 +805,20 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { p.reportError(from, "exec", err); return null; }; + pane.command_from = winid(p, src); echoCommand(p, pane, line); placeNew(p, from, from, free, .command); noteRun(p, pane, "run", line); return free; } +/// The serial a command's `$winid` names: the pane it was run from, none +/// (0) from a column's or the workspace's tag. +fn winid(p: *const Pardes, src: ?*Pane) u32 { + if (p.exec_header) return 0; + return if (src) |s| s.serial else 0; +} + /// `% line` above a command's output, every command, as a shell echoes /// what it was given; its newlines the terminal's, so a multi-line command /// does not staircase. @@ -1360,7 +1360,13 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { defer _ = libc.close(fd); const end = libc.lseek(fd, 0, libc.SEEK.END); - if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; + // Not seekable: a pipe or a FIFO is no file; a regular file that is a + // stream (a 9ns mount's log or screen, opened nonseekable) is read to + // its end, as one with no length is. + if (end < 0 and libc.errno(end) == .SPIPE) { + const facts = ninep_io.statNoFollow(path_z) orelse return error.NotAFile; + if (facts.mode & 0o170000 != 0o100000) return error.NotAFile; + } const size: usize = if (end < 0) 0 else @intCast(end); if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; if (size == 0) { diff --git a/src/host_io.zig b/src/host_io.zig index ebe910aa..4ebac9ee 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1255,7 +1255,12 @@ pub const ChildEnv = struct { return line[0 .. std.mem.indexOfScalar(u8, line, '=') orelse line.len]; } - pub fn build(slots: *Slots) ?[*:null]const ?[*:0]const u8 { + /// Which pager a child is given where the user's environment names none: + /// a command pane's one-shot shell `cat` (its output is a pane already), + /// a terminal's shell `pardes -` (a +Pager pane) unless `Pager off`. + pub const Paging = enum { command, terminal, terminal_off }; + + pub fn build(slots: *Slots, paging: Paging) ?[*:null]const ?[*:0]const u8 { var n: usize = 0; var i: usize = 0; while (libc.environ[i]) |entry| : (i += 1) { @@ -1268,6 +1273,24 @@ pub const ChildEnv = struct { slots[n] = entry; n += 1; } + // No pager to wait in (less holds a run at its first screen for + // ever): a command pane's output is read whole, through cat; a + // terminal's goes to a +Pager pane. Only where the inherited + // environment chose none. + const defaults: []const [*:0]const u8 = switch (paging) { + .command => &cat_pagers, + .terminal => pardesPagers(), + .terminal_off => &.{}, + }; + for (defaults) |default| { + const set = for (libc.environ[0..i]) |entry| { + if (std.mem.eql(u8, name(entry.?), name(default))) break true; + } else false; + if (set) continue; + if (n + own.len + 2 > slots.len) return null; + slots[n] = default; + n += 1; + } for (own) |mine| { slots[n] = mine; n += 1; @@ -1275,6 +1298,28 @@ pub const ChildEnv = struct { slots[n] = null; return @ptrCast(slots); } + + const cat_pagers = [_][*:0]const u8{ "PAGER=cat", "GIT_PAGER=cat" }; + + /// `PAGER='<this pardes> -'`, its own path so no PATH lookup can find + /// another; plain `pardes -` where the path cannot be read or quoted. + fn pardesPagers() []const [*:0]const u8 { + const S = struct { + var pager: [std.fs.max_path_bytes + 16:0]u8 = undefined; + var git_pager: [std.fs.max_path_bytes + 16:0]u8 = undefined; + var entries: [2][*:0]const u8 = undefined; + var ready = false; + }; + if (!S.ready) { + var exe_buf: [std.fs.max_path_bytes]u8 = undefined; + const n = if (comptime builtin.os.tag == .linux) libc.readlink("/proc/self/exe", &exe_buf, exe_buf.len) else -1; + const exe: []const u8 = if (n > 0 and @as(usize, @intCast(n)) < exe_buf.len and std.mem.indexOfScalar(u8, exe_buf[0..@intCast(n)], '\'') == null) exe_buf[0..@intCast(n)] else "pardes"; + S.entries[0] = if (std.fmt.bufPrintSentinel(&S.pager, "PAGER='{s}' -", .{exe}, 0)) |t| t.ptr else |_| "PAGER=pardes -"; + S.entries[1] = if (std.fmt.bufPrintSentinel(&S.git_pager, "GIT_PAGER='{s}' -", .{exe}, 0)) |t| t.ptr else |_| "GIT_PAGER=pardes -"; + S.ready = true; + } + return &S.entries; + } }; test "the child environment replaces the launcher's terminal identity exactly once" { @@ -1299,11 +1344,32 @@ test "the child environment replaces the launcher's terminal identity exactly on defer _ = unsetenv("PARDES_CHILD_ENV_PROBE"); var slots: ChildEnv.Slots = undefined; - const envp = ChildEnv.build(&slots) orelse return error.EnvironmentTooLarge; + // Where the user named no pager: a terminal's shell pages into a + // +Pager pane (`pardes -`), or, Pager off, as the environment says; a + // command pane's through cat. A pager the user named is kept. + for ([_]ChildEnv.Paging{ .terminal, .terminal_off }) |paging| { + var shell_slots: ChildEnv.Slots = undefined; + const shell_envp = ChildEnv.build(&shell_slots, paging) orelse return error.EnvironmentTooLarge; + var said: usize = 0; + var j: usize = 0; + while (shell_envp[j]) |entry| : (j += 1) { + const line = std.mem.span(entry); + if (std.mem.startsWith(u8, line, "PAGER=") and std.c.getenv("PAGER") == null) { + try std.testing.expect(paging == .terminal and std.mem.endsWith(u8, line, " -")); + said += 1; + } + } + if (std.c.getenv("PAGER") == null) try std.testing.expectEqual(@as(usize, if (paging == .terminal) 1 else 0), said); + } + const envp = ChildEnv.build(&slots, .command) orelse return error.EnvironmentTooLarge; var terms: usize = 0; var colorterms: usize = 0; var probes: usize = 0; var dumb = false; + var pagers: usize = 0; + var git_pagers: usize = 0; + var pager_cat = false; + var git_pager_cat = false; var i: usize = 0; while (envp[i]) |entry| : (i += 1) { const line = std.mem.span(entry); @@ -1311,7 +1377,16 @@ test "the child environment replaces the launcher's terminal identity exactly on if (std.mem.startsWith(u8, line, "COLORTERM=")) colorterms += 1; if (std.mem.eql(u8, line, "TERM=dumb")) dumb = true; if (std.mem.eql(u8, line, "PARDES_CHILD_ENV_PROBE=1")) probes += 1; + if (std.mem.startsWith(u8, line, "PAGER=")) pagers += 1; + if (std.mem.startsWith(u8, line, "GIT_PAGER=")) git_pagers += 1; + if (std.mem.eql(u8, line, "PAGER=cat")) pager_cat = true; + if (std.mem.eql(u8, line, "GIT_PAGER=cat")) git_pager_cat = true; } + // A pager only where the user named none: cat, so nothing waits in less. + try std.testing.expectEqual(@as(usize, 1), pagers); + try std.testing.expectEqual(@as(usize, 1), git_pagers); + if (std.c.getenv("PAGER") == null) try std.testing.expect(pager_cat); + if (std.c.getenv("GIT_PAGER") == null) try std.testing.expect(git_pager_cat); try std.testing.expectEqual(@as(usize, 1), terms); try std.testing.expectEqual(@as(usize, 1), colorterms); try std.testing.expectEqual(@as(usize, 1), probes); @@ -1323,6 +1398,14 @@ test "the child environment replaces the launcher's terminal identity exactly on extern "c" fn unsetenv(name: [*:0]const u8) c_int; +fn exportWinid(serial: u32) void { + var buf: [16]u8 = undefined; + if (serial != 0) if (std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0)) |text| { + if (Shell.setenv("winid", text, 1) == 0) return; + } else |_| {}; + _ = unsetenv("winid"); +} + /// The longest line a command pane runs (exec.zig refuses a longer one). pub const exec_command_max = pardes.exec.command_max; @@ -1390,6 +1473,9 @@ pub fn forkShell( if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0, if (core) |c| !c.opts.nested else false, ); + // acme's `$winid`: the pane a command was run from, so a script it runs + // can address that pane; unset for a shell and a header tag's command. + exportWinid(if (core) |c| (if (c.panes[pane]) |pn| (if (pn.command != null) pn.command_from else 0) else 0) else 0); if (helper) |path| { const c = core.?; const pn = c.panes[pane].?; @@ -1403,7 +1489,10 @@ pub fn forkShell( // not call setenv, whose malloc can deadlock against a pty reader thread // that held the heap when the fork took its snapshot. var env_slots: ChildEnv.Slots = undefined; - const envp = ChildEnv.build(&env_slots); + const paging: ChildEnv.Paging = if (one_line != null) + .command + else if (core) |c| (if (c.settings.pager == .off) .terminal_off else .terminal) else .terminal; + const envp = ChildEnv.build(&env_slots, paging); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; // The child says here why it could not become the shell (its chdir or // its exec failed, as for a script whose interpreter is not there): the diff --git a/src/look.zig b/src/look.zig index 4d23582b..922504c0 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1238,6 +1238,15 @@ test "a miss names a long path by its start and its end, its middle given up to try std.testing.expectEqualStrings("short/path.txt", missText("short/path.txt", &cut)); } +/// A file that is there but would not open: said with its path, `look: +/// <path>: permission denied`, the path cut in its middle when long. +fn lookOpenFailed(p: *Pardes, id: usize, path: []const u8, err: anyerror) void { + if (@import("Messages.zig").dialReason(err) != null) return p.reportError(id, path, err); + var cut: [miss_path_cap + "…".len]u8 = undefined; + var said: [miss_path_cap + 16]u8 = undefined; + p.reportError(id, std.fmt.bufPrint(&said, "look: {s}", .{missText(path, &cut)}) catch "look", err); +} + fn missedPage(p: *Pardes, id: usize, path: []const u8, page: usize) void { var cut: [miss_path_cap + "…".len]u8 = undefined; missed(p, id, "{s} has no page {d}", .{ missText(path, &cut), page }); @@ -1262,6 +1271,24 @@ fn zeroColumn(rest: []const u8) bool { return false; } +/// Whether `text` ends in a `:line:col` place (each spelling zeroColumn +/// knows) whose column is 0, line 0 included. +fn zeroColumnTail(text: []const u8) bool { + var i: usize = 0; + while (std.mem.indexOfScalarPos(u8, text, i, ':')) |at| : (i = at + 1) { + const rest = text[at..]; + for (rest[1..]) |c| if (std.mem.indexOfScalar(u8, "0123456789:-", c) == null) break else { + if (rest.len > 1 and std.ascii.isDigit(rest[1]) and zeroColumn(rest)) return true; + }; + } + return false; +} + +test "a zero column ends a look's text, line 0 included" { + for ([_][]const u8{ "f.txt:0:0", "f.txt:3:0", "dir/f.txt:3:2-0" }) |t| try std.testing.expect(zeroColumnTail(t)); + for ([_][]const u8{ "f.txt:3", "f.txt:3:1", "f.txt:0", "f.txt", "a:b:0x" }) |t| try std.testing.expect(!zeroColumnTail(t)); +} + test "a zero column after a path is found, in every spelling a look takes" { for ([_][]const u8{ ":3:0", ":3:0-4", ":3:2-0", ":3:2-5:0" }) |rest| try std.testing.expect(zeroColumn(rest)); for ([_][]const u8{ "", ":3", ":3:1", ":3:2-5", ":3:2-5:7", ":30:10" }) |rest| try std.testing.expect(!zeroColumn(rest)); @@ -1567,16 +1594,45 @@ pub fn diffAddress(pane: *const Pane, lines: []const []const u8, row: usize, buf return target.address(buf); } +/// What a click at byte `col` of line `row` of a diff pane takes, the one +/// answer for every way a click arrives (a mouse click, a 9P look of the +/// whole line, an empty look or exec written back to `event`): a file or +/// hunk header anywhere, or a hunk line on its `+`/`-`/` ` prefix, is the +/// address the line names (diffAddress); a hunk line's code is words, from +/// `word_from` on, the prefix never one of them. Null when the pane shows +/// no diff or the line is no part of one. +pub const DiffClick = struct { address: ?[]const u8 = null, word_from: usize = 0 }; + +pub fn diffClick(p: *Pardes, pane: *Pane, row: usize, col: usize, buf: []u8) ?DiffClick { + const lines = diffLines(p, pane) orelse return null; + return diffClickIn(pane, lines, row, col, buf); +} + +fn diffClickIn(pane: *const Pane, lines: []const []const u8, row: usize, col: usize, buf: []u8) ?DiffClick { + if (row >= lines.len) return null; + const line = lines[row]; + const address = diffAddress(pane, lines, row, buf) orelse return null; + const target = diff.targetAt(lines, row, pane.file == null) orelse return null; + const prefixed = line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null; + switch (target.kind) { + .context, .added, .removed => if (!(col == 0 and prefixed)) return .{ .word_from = @intFromBool(prefixed) }, + else => {}, + } + return .{ .address = address }; +} + /// A look written over 9P of a whole line of a diff pane (the cursor's line -/// first, then the first after it): its address, as a click there makes. +/// first, then the first after it): what a click on its first column takes. +/// A blank context line (` `) is a line too. fn diffLineAddress(p: *Pardes, pane: *Pane, operand: []const u8, buf: []u8) ?[]const u8 { const text = std.mem.trimEnd(u8, operand, "\r\n"); - if (std.mem.trim(u8, text, " \t").len == 0) return null; + if (text.len == 0) return null; const lines = diffLines(p, pane) orelse return null; const cur: usize = @intCast(@max(0, pane.body.cur_row)); for (0..lines.len) |k| { const i = (cur + k) % lines.len; - if (std.mem.eql(u8, std.mem.trimEnd(u8, lines[i], "\r"), text)) return diffAddress(pane, lines, i, buf); + if (std.mem.eql(u8, std.mem.trimEnd(u8, lines[i], "\r"), text)) + return (diffClickIn(pane, lines, i, 0, buf) orelse return null).address; } return null; } @@ -1616,6 +1672,10 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { const trimmed = std.mem.trim(u8, txt, " \t\r\n"); // acme's `file:addr` and `:addr` (look.c:450-543): an address after // the colon, evaluated in that file from its dot (openfile, look.c:675). + // `file:3:0`, and `file:0:0` too: columns count from 1, as addr's do, + // and a zero is refused in addr's words rather than taken as no column + // (or, after line 0, as an address). + if (zeroColumnTail(trimmed)) return p.reportFailure(id, @import("ninep/addr.zig").e_col_zero); if (addressed(trimmed)) |ad| return lookAddress(p, id, ad.path, ad.expr); // `:N` on a PDF is its page N, never a search for the text. if (comptime pdf_enabled) if (pane.hasPdf() and trimmed.len > 1 and trimmed[0] == ':') @@ -1623,7 +1683,6 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { const pl = parsePathLine(trimmed); // `file:3:0`: columns count from 1, as addr's do, and a zero is refused // in addr's words rather than taken as no column. - if (pl.at.line != 0 and zeroColumn(trimmed[pl.path.len..])) return p.reportFailure(id, @import("ninep/addr.zig").e_col_zero); if (comptime pdf_enabled) if (pane.hasPdf()) clearNavigationSelection(p, pane); if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; var realbuf: [4096]u8 = undefined; @@ -1711,7 +1770,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { if (!focusPaneByPath(p, target.path, target.at)) { const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| - return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err); + return lookOpenFailed(p, id, target.path, err); if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1); const failures = p.fs.failures; exec.placeNew(p, id, id, free, .doc); @@ -1733,7 +1792,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { if (focusPaneByPath(p, target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const made = panes.Image.create(p, free, target.path, &.{}) catch |err| - return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err); + return lookOpenFailed(p, id, target.path, err); // One it cannot read (corrupt, truncated, not what its name says) // is said, a failure, rather than shown as a blank pane. if (!@import("image.zig").readable(made.image.?.raw)) { @@ -2230,6 +2289,69 @@ test "Look on a diff: headers and a hunk line's prefix are the whole line, hover } } +test "a click, a 9P whole-line look and an empty event write-back take one expansion on every kind of diff line, a blank context line too" { + if (!platform_has_fs) return; + const diff_text = + \\diff --git a/src/new.zig b/src/new.zig + \\--- a/src/new.zig + \\+++ b/src/new.zig + \\@@ -2,3 +2,3 @@ fn x() void { + \\ two + \\-drei + \\+three + \\ + \\ + ; + var fx: DiffFixture = undefined; + try fx.init(true, &.{.{ "src/new.zig", "one\ntwo\nthree\n\nfive\n" }}, diff_text); + defer fx.tmp.cleanup(); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try fx.open(p, diff_text); + pane.tag_expanded = false; + const Point = struct { row: u16, col: u16, want: []const u8, whole_line: bool = true }; + const points = [_]Point{ + .{ .row = 0, .col = 0, .want = "src/new.zig" }, // diff --git + .{ .row = 1, .col = 0, .want = "src/new.zig" }, // --- + .{ .row = 2, .col = 0, .want = "src/new.zig" }, // +++ + .{ .row = 3, .col = 0, .want = "src/new.zig:2" }, // @@ + .{ .row = 4, .col = 0, .want = "src/new.zig:2" }, // a context line's ` ` + .{ .row = 5, .col = 0, .want = "src/new.zig:3" }, // `-` + .{ .row = 6, .col = 0, .want = "src/new.zig:3" }, // `+` + .{ .row = 7, .col = 0, .want = "src/new.zig:4" }, // a blank context line + .{ .row = 6, .col = 3, .want = "three", .whole_line = false }, // code: a word + }; + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + for (points) |at| { + p.active = 0; + pane.file.?.scroll = 0; + pane.body.cur_row = 0; + p.sync(); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + // The mouse's. + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + at.col; + const y = p.bodyTop(pane, rect) + at.row; + const pointed = edit.pointerTextSelection(p, 0, x, y, null, null) orelse return error.NothingPointed; + const clicked = exec.pointerOperand(p, pane, pointed.sel).text orelse return error.NothingClicked; + try std.testing.expectEqualStrings(at.want, clicked); + // An empty record written back to event, at the same byte. + const body = pane.file.?.content; + const line_start = modal.lineStartOffset(body, at.row); + const written = pardes.ctlfs.events.expandAt(p, pane, body, line_start + at.col, true); + try std.testing.expectEqualStrings(at.want, written); + // A 9P look of the whole line. + if (at.whole_line) { + const lines = diffLines(p, pane).?; + var buf: [4096 + 32]u8 = undefined; + const whole = diffLineAddress(p, pane, lines[at.row], &buf) orelse return error.NoWholeLineAddress; + try std.testing.expectEqualStrings(at.want, whole); + } + } +} + test "Look outside a diff pane expands and looks as ever" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); defer p.deinit(); diff --git a/src/main.zig b/src/main.zig index 3218fef1..8818645e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -46,11 +46,13 @@ pub const debug = if (is_emscripten) struct {} else struct { }; const help_text = - \\Usage: pardes [options] [file|dir] + \\Usage: pardes [options] [file|dir|-] \\ \\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. + \\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. \\ \\Options: \\ --tty start as one shell pane already in tty mode @@ -98,6 +100,95 @@ const help_text = \\ ; +/// 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. +fn readStdinText(gpa: std.mem.Allocator) ![]u8 { + const limit = pardes.memory.limits.max_file_bytes; + var raw: std.ArrayList(u8) = .empty; + var chunk: [64 * 1024]u8 = undefined; + var cut = false; + while (true) { + const n = std.c.read(0, &chunk, chunk.len); + if (n < 0) { + if (std.posix.errno(n) == .INTR) continue; + return error.ReadFailed; + } + if (n == 0) break; + const got: usize = @intCast(n); + if (raw.items.len + got > limit) { + try raw.appendSlice(gpa, chunk[0 .. limit - raw.items.len]); + cut = true; + break; + } + 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; + } + 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" }, + }; + for (cases) |c| { + const got = try stripEscapes(gpa, c[0]); + defer gpa.free(got); + try std.testing.expectEqualStrings(c[1], got); + } +} + /// A forwarding failure in words, never an error's name: the ones this /// file raises said outright, any other as its name's words (`NotFound`, /// `not found`), a dial's as Messages says it. @@ -106,6 +197,7 @@ fn forwardWords(err: anyerror, buf: []u8) []const u8 { error.NotOneLine => "a file name is one line, and this one holds a newline", error.NotAFileName => "names a directory, not a file", error.NoWorkingDirectory => "this shell's working directory is gone", + error.DirectoryNotWritable => "its directory may not be read or written, so it could never be saved", error.NotAPaneAddress => "not a pane address: @p and a pane's number", error.NoSuchPane => "this session has no such pane", else => if (pardes.Messages.dialReason(err)) |why| why else words: { @@ -368,7 +460,8 @@ fn nativeMain(init: std.process.Init) !void { } else if (std.mem.eql(u8, a, "--version")) { try std.Io.File.stdout().writeStreamingAll(init.io, version_text); return; - } else if (a.len > 0 and a[0] != '-' and positional == null) { + } else if (a.len > 0 and (a[0] != '-' or std.mem.eql(u8, a, "-")) and positional == null) { + // `-` is stdin, as for cat and vim: a text to show. session_only = false; positional = a; } else if (a.len == 0) { @@ -439,6 +532,24 @@ fn nativeMain(init: std.process.Init) !void { // itself, which is what a right click in that pane would have been. var look_buf: [64]u8 = undefined; const look = try std.fmt.bufPrint(&look_buf, "/pane/{d}/look", .{parent.serial}); + // `pardes -`: stdin read to its end, a +Pager pane of the session + // made of it (as acme's `B` has no stdin, through pane/new, a name + // and the body), and back at once: the PAGER a pane's shell gets. + if (positional) |word| if (std.mem.eql(u8, word, "-")) { + const text = readStdinText(arena) catch |err| Refuse.with(init.io, "stdin", err); + const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch |err| Refuse.with(init.io, "-", err); + const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch |err| Refuse.with(init.io, "-", err); + var cwd_buf: [4096]u8 = undefined; + const cwd = if (std.c.getcwd(&cwd_buf, cwd_buf.len)) |c| std.mem.span(@as([*:0]u8, @ptrCast(c))) else "/"; + var path_buf: [64]u8 = undefined; + var line_buf: [4200]u8 = undefined; + const name_line = std.fmt.bufPrint(&line_buf, "{s}/+Pager\n", .{std.mem.trimEnd(u8, cwd, "/")}) catch "/+Pager\n"; + ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/name", .{serial}), name_line) catch |err| Refuse.with(init.io, "-", err); + if (text.len > 0) ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/body", .{serial}), text) catch |err| Refuse.with(init.io, "-", err); + // Shown, not edited: nothing to ask about at Exit. + ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/ctl", .{serial}), "clean\n") catch {}; + return; + }; const typed_word = positional orelse { var tag_buf: [64]u8 = undefined; const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial}); @@ -483,6 +594,15 @@ fn nativeMain(init: std.process.Init) !void { const cwd = std.mem.sliceTo(&cwd_buf, 0); break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch |err| Refuse.with(init.io, word, err); }; + // A directory that is there but may not be read or written: a + // pane for the name could only fail at its Save, so refused now. + var dir_z_buf: [4096]u8 = undefined; + if (std.fmt.bufPrintSentinel(&dir_z_buf, "{s}", .{dir.path}, 0)) |dir_z| { + const R_OK = 4; + const W_OK = 2; + const X_OK = 1; + if (std.c.access(dir_z.ptr, R_OK | W_OK | X_OK) != 0) Refuse.with(init.io, word, error.DirectoryNotWritable); + } else |_| {} break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch |err| Refuse.with(init.io, word, err); }; var made_serial: ?u32 = null; @@ -520,6 +640,12 @@ fn nativeMain(init: std.process.Init) !void { if (wait) waitForDel(init.io, arena, parent.dial, path, made_serial); return; } + // `pardes -` outside a session: an editor whose first pane holds stdin, + // as `vim -` does; the terminal is /dev/tty, never stdin. + if (positional) |a| if (std.mem.eql(u8, a, "-")) { + opts.stdin_text = readStdinText(arena) catch |err| badArgs(init.io, "stdin: {s}", .{@errorName(err)}); + positional = null; + }; if (positional) |a| { const target = @import("look.zig").parsePathLine(a); if (std.mem.eql(u8, target.path, "/n") or std.mem.startsWith(u8, target.path, "/n/") or diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index b2e62d28..e3b456ba 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -167,10 +167,10 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { } else false; // Plan 9's words for it, so a mount's errno is EINVAL as the root's // and a pane's ctl give (9ns reads "control message"). - if (!takes) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "unknown control message \"{s}\": a column's ctl takes Delcol, Joincol, New or Tty", .{line[0..@min(line.len, 64)]}) catch "unknown control message"); + if (!takes) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "unknown control message \"{s}\": a column's ctl takes Delcol, Joincol, New or Tty", .{pardes.ctlfs.ctl.firstWord(line)[0..@min(pardes.ctlfs.ctl.firstWord(line).len, 64)]}) catch "unknown control message"); // Only Tty takes a word (its shell); `New extra` is no New. if (!std.mem.eql(u8, word, "Tty") and word.len != line.len) - return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "wrong #args in control message \"{s}\"", .{line[0..@min(line.len, 64)]}) catch "wrong #args in control message"); + return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "wrong #args in control message \"{s}\"", .{pardes.ctlfs.ctl.firstWord(line)[0..@min(pardes.ctlfs.ctl.firstWord(line).len, 64)]}) catch "wrong #args in control message"); const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT); p.exec_column = col; defer p.exec_column = null; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 31265a94..87339b70 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -296,7 +296,7 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i /// The errno a builtin's failure answers with: ENOENT for what is not /// there (`no such`, `not found`, the words 9ns reads as ENOENT too), else /// EIO. -fn failureErrno(failure: []const u8) u16 { +pub fn failureErrno(failure: []const u8) u16 { if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT; // A pattern refused (Edit's too) is malformed input, as 9ns reads it; // so is a word this build has not (`invalid: acme's Put ...`). @@ -422,23 +422,39 @@ const Builtin = builtins.registry.Builtin(); /// Refuses a control message, quoting it the way Plan 9's cmderror does /// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`. fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { - const room = p.fs.ename.len -| (why.len + 3); - var shown: [320]u8 = undefined; - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, blanked(line[0..@min(line.len, room)], &shown) }) catch why; - return tree.failText(req.tag, E.INVAL, text); + const word = firstWord(line); + // A word too long to quote whole gives up its middle (fitErr), so the + // refusal keeps both its ends. + var shown: [4096]u8 = undefined; + var whole: [4096 + 320]u8 = undefined; + const full = std.fmt.bufPrint(&whole, "{s} \"{s}\"", .{ why, blanked(word[0..@min(word.len, shown.len)], &shown) }) catch why; + // Into the session's own buffer: `whole` is this frame's. + if (full.len <= p.fs.ename.len) { + @memcpy(p.fs.ename[0..full.len], full); + return tree.failText(req.tag, E.INVAL, p.fs.ename[0..full.len]); + } + return tree.failText(req.tag, E.INVAL, tree.fitErr(full, &p.fs.ename)); +} + +/// The word a refusal quotes: a control message's first, its offending +/// word, never the line with its arguments (one rule everywhere: `wrong +/// #args in control message "Newcol"`). +pub fn firstWord(line: []const u8) []const u8 { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + return trimmed[0 .. std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len]; } /// `refuse`, saying which ctl takes the message instead. pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply { const room = p.fs.ename.len -| (why.len + ctl.len + 18); var shown: [320]u8 = undefined; - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(line[0..@min(line.len, room)], &shown), ctl }) catch why; + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(firstWord(line)[0..@min(firstWord(line).len, room)], &shown), ctl }) catch why; return tree.failText(req.tag, E.INVAL, text); } /// A quoted line as a refusal shows it: a control byte (a tab, the one a /// line may hold) is a blank, so the reason reads as one line of words. -fn blanked(line: []const u8, buf: *[320]u8) []const u8 { +fn blanked(line: []const u8, buf: []u8) []const u8 { const n = @min(line.len, buf.len); for (line[0..n], buf[0..n]) |c, *o| o.* = if (c < ' ' or c == 0x7f) ' ' else c; return buf[0..n]; @@ -1087,7 +1103,8 @@ test "a control character in a write to any ctl refuses the whole write, and a q } const quoted = wr(p, Node.of(serialOf(p), .ctl), "bogus\tword\n"); try testing.expectEqual(E.INVAL, quoted.errno()); - try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "bogus word") != null); + // The offending word alone is quoted: a tab ends it. + try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "\"bogus\"") != null); try testing.expect(std.mem.indexOfScalar(u8, quoted.reply.ename, '\t') == null); } @@ -1209,11 +1226,11 @@ test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the r const refused = wr(p, ctl_node, bad); try testing.expectEqual(E.INVAL, refused.errno()); var want: [64]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "unknown control message \"{s}\"", .{bad}), refused.reply.ename); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "unknown control message \"{s}\"", .{firstWord(bad)}), refused.reply.ename); } try testing.expectEqualStrings("not a window control message \"Newcol\": write it to /ctl", wr(p, ctl_node, "Newcol").reply.ename); - try testing.expectEqualStrings("not a window control message \"Verbose off\": write it to /ctl", wr(p, ctl_node, "Verbose off").reply.ename); - try testing.expectEqualStrings("wrong #args in control message \"Collapse now\"", wr(p, ctl_node, "Collapse now").reply.ename); + try testing.expectEqualStrings("not a window control message \"Verbose\": write it to /ctl", wr(p, ctl_node, "Verbose off").reply.ename); + try testing.expectEqualStrings("wrong #args in control message \"Collapse\"", wr(p, ctl_node, "Collapse now").reply.ename); // Checked whole first: a bad line anywhere and nothing ran. try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nbogus").errno()); try testing.expect(p.panes[0].?.msg_len == 0 or !std.mem.eql(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "first")); @@ -1265,18 +1282,18 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi try testing.expectEqual(columns + 1, p.ncol); try testing.expectEqualStrings("not a session control message \"Del\": write it to pane/<n>/ctl", wr(p, root_ctl, "Del").reply.ename); - try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename); - try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename); - try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename); + try testing.expectEqualStrings("unknown control message \"Nonsense\"", wr(p, root_ctl, "Nonsense 1").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose\"", wr(p, root_ctl, "Verbose maybe").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement\"", wr(p, root_ctl, "Placement east").reply.ename); // A number's refusal names its range, a path's what it takes. - try testing.expectEqualStrings("bad value in control message; takes 0-100 (a percentage) \"InactiveDim 200\"", wr(p, root_ctl, "InactiveDim 200").reply.ename); - try testing.expectEqualStrings("bad value in control message; takes 0-60000 (milliseconds) \"MessageLinger x\"", wr(p, root_ctl, "MessageLinger x").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes 0-100 (a percentage) \"InactiveDim\"", wr(p, root_ctl, "InactiveDim 200").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes 0-60000 (milliseconds) \"MessageLinger\"", wr(p, root_ctl, "MessageLinger x").reply.ename); for (config.Runtime.settings) |setting| if (setting.action != .theme) try testing.expect(config.Runtime.takes(setting.action) != null); const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename; try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n"); - try testing.expect(std.mem.endsWith(u8, no_theme, "; Themes lists all \"Theme no-such-theme\"")); - try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename); + try testing.expect(std.mem.endsWith(u8, no_theme, "; Themes lists all \"Theme\"")); + try testing.expectEqualStrings("wrong #args in control message \"Newcol\"", wr(p, root_ctl, "Newcol 2").reply.ename); try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename); // A bare required word fails the check, so the line before never runs. try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Verbose on\nMount").errno()); @@ -1760,6 +1777,71 @@ test "exec Save writes the named file through the host" { try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); } +test "a refusal's +Unsaved goes in the session's directory when the active pane's directory is not there" { + if (comptime !pardes.hosted) return; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/x.txt\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "edited\n").reply.status); + try testing.expectEqual(Status.err, wr(p, @intFromEnum(tree.TopFile.ctl), "Exit\n").reply.status); + const unsaved = for (p.panes) |slot| { + const pane = slot orelse continue; + const f = pane.file orelse continue; + if (f.output) |o| if (o.from == .unsaved) break f.path; + } else return error.NoUnsavedPane; + try testing.expectEqualStrings(p.sessionDir(), std.fs.path.dirname(unsaved).?); +} + +test "a look at a file it may not read fails the write naming the file" { + if (comptime !pardes.hosted) return; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "locked.txt", .data = "secret\n", .flags = .{ .permissions = .fromMode(0o000) } }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + const path = try std.fmt.bufPrint(&line, "{s}/locked.txt", .{dir}); + var want: [4200]u8 = undefined; + const expected = try std.fmt.bufPrint(&want, "look: {s}: permission denied", .{path}); + var with_newline: [4300]u8 = undefined; + const refused = wr(p, root_look, try std.fmt.bufPrint(&with_newline, "{s}\n", .{path})); + if (refused.reply.status == .ok) return error.SkipZigTest; // root reads anything + try testing.expectEqualStrings(expected, refused.reply.ename); +} + +test "Pager is a setting of the root ctl: pardes by default, off taken, anything else refused with its choices" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const root_ctl = @intFromEnum(tree.TopFile.ctl); + try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Pager pardes\n") != null); + try testing.expectEqual(Status.ok, wr(p, root_ctl, "Pager off\n").reply.status); + try testing.expectEqual(config.Runtime.Pager.off, p.settings.pager); + try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Pager off\n") != null); + try testing.expectEqualStrings("bad value in control message; takes pardes, off \"Pager\"", wr(p, root_ctl, "Pager less\n").reply.ename); +} + +test "a refused Del written back through event fails that write, EIO, with its err" { + const gpa = testing.allocator; + const p = try withFile(gpa, "text\n"); + defer p.deinit(); + const serial = serialOf(p); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "more\n").reply.status); // unsaved + const event = Node.of(serial, .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + defer _ = call(p, .{ .tag = 9, .op = .release, .node = event, .handle = h }); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status); + const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes; + var copy: [256]u8 = undefined; + @memcpy(copy[0..record.len], record); + const back = wr(p, event, copy[0..record.len]); + try testing.expectEqual(E.IO, back.errno()); + try testing.expect(std.mem.indexOf(u8, back.reply.ename, "Modified") != null); + try testing.expect(p.paneBySerial(serial) != null); +} + test "a pane's own look and exec go to the event reader holding it, which writes them back to act" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg routed\n"); @@ -1951,7 +2033,7 @@ test "Joincol with no column to the right and Theme with no such theme say so" { try testing.expectEqual(E.INVAL, themed.errno()); try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: "); try testing.expect(std.mem.indexOf(u8, themed.reply.ename, ": dr") != null); - try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\"")); + try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme\"")); // A click on the word says it on the message row. _ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n"); const pane = p.panes[p.active].?; @@ -2089,8 +2171,8 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); if (!builtins.capabilities.lift) { - try testing.expectEqualStrings("Lift is GUI-only, invalid here \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename); - try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); + try testing.expectEqualStrings("Lift is GUI-only, invalid here \"Lift\"", wr(p, root_ctl, "Lift on\n").reply.ename); + try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); // A builtin the GUI alone has, written to a ctl or an exec, never // runs as a shell command. if (comptime !pardes.font_picker) { @@ -2100,7 +2182,7 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back try testing.expectEqualStrings("Fonts is GUI-only, invalid here", ran.reply.ename); } } - try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename); + try testing.expectEqualStrings("unknown control message \"Liftt\"", wr(p, root_ctl, "Liftt on\n").reply.ename); var buf: [900]u8 = undefined; if (pardes.dump.directory(&buf, "")) |dir| { var want: [920]u8 = undefined; @@ -2945,13 +3027,14 @@ test "every EINVAL a write gets says why, in its err record too; DEL is a contro try testing.expect(th.logHas(p, "an empty name")); } -test "/index shows a name as the log does: a newline in it is \\n, controls spaces, bytes not UTF-8 \\xNN" { +test "/index shows a name as the log does: a newline in it is \\n, a control byte, C1 or byte not UTF-8 \\xNN" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const term = p.paneBySerial(serialOf(p)).?; - p.setCwd(term, "/tmp/two\nlines\x7f\xff"); + p.setCwd(term, "/tmp/two\nlines\x07\x7f\xc2\x85\xff"); const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes; - try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines \\xff ") != null); + // Each byte as itself, so the name decodes to what it is. + try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines\\x07\\x7f\\xc2\\x85\\xff ") != null); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, index, "\n")); try testing.expect(index.len > 0); try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(tree.TopFile.index) }).reply.attr.size); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index bacf3ad6..c0b52d03 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -6,6 +6,7 @@ const exec = @import("../exec.zig"); const look = @import("../look.zig"); const cloud9 = @import("cloud9"); const tree = @import("tree.zig"); +const ctl = @import("ctl.zig"); const pane_files = @import("pane.zig"); const dump = @import("../dump.zig"); @@ -336,9 +337,10 @@ fn sanitize(record: []const u8, out: []u8) []u8 { /// `text` as one line of UTF-8 a reader can split and decode, as /log and /// /index show names: a backslash is `\\`, a newline `\n` (a name with one -/// stays readable as what it is, not run into the next), any other control -/// character, DEL or C1 control (U+0080-U+009F) a space, and a byte that -/// is not UTF-8 `\xNN`. `out` of 4 bytes a byte of `text` holds it all. +/// stays readable as what it is, not run into the next), and any other +/// control character, DEL, C1 control (U+0080-U+009F) or byte that is not +/// UTF-8 `\xNN`, so a name read there decodes to the bytes it is. `out` of +/// 4 bytes a byte of `text` holds it all. pub fn shown(text: []const u8, out: []u8) []u8 { return shownAs(text, out, true); } @@ -364,8 +366,15 @@ fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { continue; } if (c < ' ' or c == 0x7f) { - out[w] = ' '; - w += 1; + // A name's (escape_newline) is `\xNN`, so it reads back as the + // byte it was; a message's a space. + if (escape_newline) { + _ = std.fmt.bufPrint(out[w..], "\\x{x:0>2}", .{c}) catch break; + w += 4; + } else { + out[w] = ' '; + w += 1; + } i += 1; continue; } @@ -377,6 +386,13 @@ fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { continue; } if (n == 2 and c == 0xC2 and body[i + 1] <= 0x9F) { + if (escape_newline) { + if (w + 8 > out.len) break; + _ = std.fmt.bufPrint(out[w..], "\\xc2\\x{x:0>2}", .{body[i + 1]}) catch break; + w += 8; + i += 2; + continue; + } out[w] = ' '; w += 1; i += 2; @@ -494,18 +510,6 @@ pub fn writeLog(p: *Pardes, req: Req) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -/// The length a stat reports: the text an open would freeze now. -pub fn logLen(q: *const Queue) u64 { - var n: u64 = 0; - var at = q.head; - while (at + 4 <= q.buf.items.len) { - const len = std.mem.readInt(u32, q.buf.items[at..][0..4], .little); - n += len; - at += 4 + len; - } - return n; -} - // ---- per-pane event records ---- pub const max_record_text = 256; @@ -697,11 +701,27 @@ fn scanNumber(data: []const u8, i: *usize) ?u32 { /// Writing a Look or Exec record back performs the action it names. /// The word a click at `at` expands to: the file name or word run under it /// on its line (look.expandedWord), else nothing. -fn expandAt(whole: []const u8, at: usize) []const u8 { +/// An empty range written back: what a click at `at` takes, as the mouse's +/// does (exec.pointerOperand). In a diff pane's body that is look.diffClick's +/// answer, the line's address or its code's words. +pub fn expandAt(p: *Pardes, pane: *Pane, whole: []const u8, at: usize, in_body: bool) []const u8 { const start = if (std.mem.lastIndexOfScalar(u8, whole[0..at], '\n')) |nl| nl + 1 else 0; const end = std.mem.indexOfScalarPos(u8, whole, at, '\n') orelse whole.len; - const b = look.expandedWord(whole[start..end], at - start) orelse return ""; - return whole[start + b.lo .. start + b.hi]; + var word_from: usize = 0; + if (in_body) diff: { + const row = std.mem.count(u8, whole[0..start], "\n"); + // Only when the served line is the one the diff reads there. + const lines = look.diffLines(p, pane) orelse break :diff; + if (row >= lines.len or !std.mem.eql(u8, std.mem.trimEnd(u8, lines[row], "\r"), std.mem.trimEnd(u8, whole[start..end], "\r"))) break :diff; + var buf: [4096 + 32]u8 = undefined; + const click = look.diffClick(p, pane, row, at - start, &buf) orelse break :diff; + if (click.address) |address| return p.scratch.allocator().dupe(u8, address) catch ""; + word_from = click.word_from; + } + const line = whole[start..end]; + if (at - start < word_from or word_from > line.len) return ""; + const b = look.expandedWord(line[word_from..], at - start - word_from) orelse return ""; + return whole[start + word_from + b.lo .. start + word_from + b.hi]; } pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { @@ -732,6 +752,11 @@ pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { } if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": a record is <origin><action><q0> <q1> <flag> <count> <text>, a line each"); } + // What a record written back runs fails the write as a click written + // to exec would (a refused Del: EIO and its err), not said and lost. + p.fs.capturing = true; + p.fs.failure_len = 0; + defer p.fs.capturing = false; var run: EventReader = .{ .data = req.data }; while (run.next()) |r| { const live = p.paneBySerial(serial) orelse break; @@ -742,7 +767,7 @@ pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { // An empty range with no text is a click at that point: expanded as // the click would be, to the word or file name under it (acme's // expand, look.c). - const said = if (r.q0 == r.q1) r.text orelse expandAt(whole, lo) else whole[lo..hi]; + const said = if (r.q0 == r.q1) r.text orelse expandAt(p, pane, whole, lo, !r.action.onTag()) else whole[lo..hi]; const text = p.scratch.allocator().dupe(u8, said) catch continue; switch (r.action) { // A body's click written back is done as the click would have @@ -811,6 +836,10 @@ pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { else => unreachable, } } + if (p.fs.failure_len > 0) { + const failure = p.fs.failure[0..p.fs.failure_len]; + return tree.failText(req.tag, ctl.failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); + } return .{ .tag = req.tag, .written = @intCast(asked.data.len) }; } @@ -981,10 +1010,10 @@ test "a pane deleted while its event file is open leaves no suppression behind" _ = wr(p, Node.of(serial, .ctl), "Del\n"); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(@as(u16, 0), p.fs.listeners); - // The reader's next read hears what acme says of a window gone under it. + // The reader's next read is its end: no more records, no error. const shut = call(p, .{ .tag = 19, .op = .read, .node = event, .handle = a.reply.handle, .size = 64 }); - try testing.expectEqual(E.NOENT, shut.errno()); - try testing.expectEqualStrings(tree.e_shut_down, shut.reply.ename); + try testing.expectEqual(tree.Status.ok, shut.reply.status); + try testing.expectEqualStrings("", shut.bytes); _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); @@ -1388,7 +1417,7 @@ test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are test "a long err record keeps its end, its middle given up to an ellipsis" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); - _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n"); + _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus" ++ "abcdefgh" ** 40 ++ "\n"); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index be03e8f0..c71a706f 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -646,7 +646,9 @@ fn topSize(p: *Pardes, f: TopFile) u64 { .README => fs.help.len, .status => ctl.statusLen(p), .look, .exec, .tagexec => ctl.resultsLen(p), - .log => events.logLen(&p.fs.log), + // A stream: a length would read as its end to a kernel that caches + // one (a 9ns mount's splice), and a follower's reads go past it. + .log => 0, .ctl => ctl.rootLen(p), .commands => ctl.commandsLen(), .focus => focus: { @@ -1092,10 +1094,14 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { // A run's answer outlives the pane it ran in. if (t.file == .pty_run and req.handle != 0) return pty.readRun(p, req); const id = p.paneBySerial(t.serial) orelse { - // An event or pty/data read the pane closed under: acme's - // answer to the same (editors/acme/xfid.c:1005). - if (openOf(p, req)) |o| if (o.what == .event or o.what == .pty_data) - return failText(req.tag, E.NOENT, e_shut_down); + // An event reader whose pane closed reads its end: there + // are no more records, as a pipe's writer gone is EOF, so + // `while read` and `cat` end cleanly. A pty/data read the + // pane closed under keeps acme's answer (xfid.c:1005). + if (openOf(p, req)) |o| { + if (o.what == .event) return .{ .tag = req.tag }; + if (o.what == .pty_data) return failText(req.tag, E.NOENT, e_shut_down); + } return Reply.fail(req.tag, E.NOENT); }; const pn = p.panes[id].?; @@ -1335,6 +1341,14 @@ test "filesystem inspection preserves pending and displayed Look hover" { try testing.expect(!p.raw_hover_intent); } +test "log stats 0 however much it holds, a stream as acme's generated files are" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + _ = th.wr(p, Node.of(th.serialOf(p), .exec), "Msg a record for the log\n"); + try testing.expect(th.logHas(p, "a record for the log")); + try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.log) }).reply.attr.size); +} + test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ // Opening `new` is what makes a pane now; walking to it makes none, diff --git a/src/panes.zig b/src/panes.zig index 0380f89f..a395d6ed 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -124,6 +124,10 @@ pub const Pane = struct { /// A command pane (exec.zig runCommand): the line its child runs as /// `$SHELL -c`, owned, where an interactive terminal has null. command: ?[]u8 = null, + /// The pane whose tag or text the command was run from, its child's + /// `$winid` (acme's name); 0 for one from a column's or the workspace's + /// tag, which no pane owns. + command_from: u32 = 0, /// The command's child has exited, and with what status (null: unknown, /// shown `exit ?`). command_done: bool = false, diff --git a/src/pardes.zig b/src/pardes.zig index 47ebf8fa..aa2c252c 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4180,6 +4180,8 @@ pub const Boot = enum { document, /// a Look that found nothing: reported, not opened missing, + /// `pardes -`: stdin's text in a +Pager pane, as `vim -` shows it + stdin, /// the firmware's boot buffer, the only pane a board has embedded, /// the bare tty startup: one shell in raw tty mode, with an empty text @@ -4196,6 +4198,7 @@ pub const Boot = enum { if (opts.boot) |named| return named; if (opts.file != null) return .document; if (opts.missing != null) return .missing; + if (opts.stdin_text != null) return .stdin; if (comptime platform == .esp32p4) return .embedded; if (opts.tty_only) return .tty_shell; // ponytail: 1 and 3 are the only shell counts anyone asked for @@ -4215,6 +4218,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. + stdin_text: ?[]const u8 = null, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, @@ -4626,6 +4631,17 @@ pub const Pardes = struct { _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); p.oneColumn(); }, + .stdin => { + const content = try gpa.dupe(u8, opts.stdin_text.?); + errdefer gpa.free(content); + const dir = p.sessionDir(); + const shown = try panes.Output.open(p, 0, dir, .{ .cmd = .New }, "", content); + // Named +Pager, as `pardes -` inside a session names its pane. + const named = try std.fs.path.join(gpa, &.{ dir, "+Pager" }); + gpa.free(shown.file.?.path); + shown.file.?.path = named; + p.oneColumn(); + }, .embedded => { const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); diff --git a/src/pdf.zig b/src/pdf.zig index 8ddca497..d98d5ff2 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -66,13 +66,17 @@ test "custom allocator owns a complete PDF document lifecycle" { } /// One raster-quality request. `minimum_*` asks MuPDF for enough source pixels -/// to avoid backend upscaling; `max_dimension` remains the hard allocation -/// ceiling for hostile page sizes and very large displays. +/// to avoid backend upscaling; `max_dimension` caps a raster row always, and +/// the page's height unless that would draw it below `display_*`, the size +/// the page is shown at (0: no floor). A page taller than that is never one +/// allocation: its owner renders bands of it (`Raster.band`). pub const RenderRequest = struct { dpi: u16 = 144, minimum_width: u32 = 0, minimum_height: u32 = 0, max_dimension: u16 = 1600, + display_width: u32 = 0, + display_height: u32 = 0, pub fn eql(a: RenderRequest, b: RenderRequest) bool { return std.meta.eql(a, b); @@ -81,8 +85,12 @@ pub const RenderRequest = struct { pub const default_render_request: RenderRequest = .{}; pub const absolute_max_render_dimension: u16 = 4096; +/// The most one render buffer holds: a whole page, or one band of a page +/// taller than this. pub const max_owned_raster_bytes: usize = @as(usize, absolute_max_render_dimension) * absolute_max_render_dimension * 4; +/// The tallest page raster any request produces (rows), whatever it is shown at. +pub const max_raster_rows: usize = c.PARDES_PDF_MAX_RASTER_ROWS; /// MuPDF's per-document eviction cache ceiling. The owned RGBA render buffer /// is allocated separately by Pardes and does not count against this store. pub const store_limit_bytes: usize = c.PARDES_PDF_STORE_LIMIT_BYTES; @@ -895,11 +903,13 @@ pub const Document = struct { bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, + bounded.display_width, + bounded.display_height, &raw_layout, ) != c.PARDES_PDF_OK) return error.RenderFailed; - const layout = try checkedRasterLayout(raw_layout); - if (layout.len > max_owned_raster_bytes) return error.PixmapTooLarge; - return layout; + // The page's shape, not an allocation: a page taller than + // `max_owned_raster_bytes` allows is rendered a band at a time. + return checkedRasterLayout(raw_layout); } /// Rasterize `band` of a page into `rgba`, which MUST be exactly @@ -932,6 +942,7 @@ pub const Document = struct { if (rgba.len != band.len) return error.BadPixmap; if (band.height == 0 or band.y + band.height > raster.height) return error.BadPixmap; + if (band.len > max_owned_raster_bytes) return error.PixmapTooLarge; const bounded = try boundedRequest(document, page, request); if (highlights.len > c.PARDES_PDF_MAX_RESULT_QUADS) return error.RenderFailed; const highlight_ptr: ?[*]const c.pardes_pdf_highlight = @@ -943,6 +954,8 @@ pub const Document = struct { bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, + bounded.display_width, + bounded.display_height, highlight_ptr, highlights.len, rgba.ptr, @@ -982,6 +995,8 @@ pub const Document = struct { bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, + bounded.display_width, + bounded.display_height, if (highlights.len == 0) null else @ptrCast(highlights.ptr), highlights.len, rgba.ptr, @@ -1015,6 +1030,8 @@ pub const Document = struct { bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, + bounded.display_width, + bounded.display_height, if (highlights.len == 0) null else @ptrCast(highlights.ptr), highlights.len, &row_start, @@ -1028,6 +1045,8 @@ pub const Document = struct { minimum_width: c_int, minimum_height: c_int, max_dimension: c_int, + display_width: c_int, + display_height: c_int, }; fn boundedRequest( @@ -1051,9 +1070,15 @@ pub const Document = struct { @as(u32, @intCast(std.math.maxInt(c_int))), )), .max_dimension = request.max_dimension, + .display_width = clampInt(request.display_width), + .display_height = clampInt(request.display_height), }; } + fn clampInt(value: u32) c_int { + return @intCast(@min(value, @as(u32, @intCast(std.math.maxInt(c_int))))); + } + fn renderInternal( document: *Document, gpa: std.mem.Allocator, @@ -1062,6 +1087,7 @@ pub const Document = struct { highlights: ?[]const Highlight, ) !Render { const raster = try document.measureRenderAt(page, request); + if (raster.len > max_owned_raster_bytes) return error.PixmapTooLarge; const rgba = try gpa.alloc(u8, raster.len); errdefer gpa.free(rgba); try document.renderIntoAt( @@ -1453,6 +1479,94 @@ pub fn makeLinksTestPdf(gpa: std.mem.Allocator) ![]u8 { return allocateGeneratedPdf(gpa, &offsets, {}, emitLinksPdf); } +/// The tall test page: 600 x 9000 pt, fifteen A4 pages' height on one. +pub const tall_test_page: PageSize = .{ .width = 600, .height = 9000 }; + +fn emitTallPdf(bytes: *PdfBytes, offsets: []usize, _: void) !void { + // A labelled row every 100 pt, each with a slanting rule, so a band + // edge or a seam between chunks cuts through text and through lines. + var storage: [16 * 1024]u8 = undefined; + var stream: std.Io.Writer = .fixed(&storage); + var row: usize = 0; + while (row * 100 + 160 < 9000) : (row += 1) { + const y = 9000 - 60 - row * 100; + stream.print( + "BT /F1 28 Tf 40 {d} Td (Row {d}) Tj ET\n0.2 0.3 0.9 RG 3 w 20 {d} m 580 {d} l S\n", + .{ y, row, y - 20, y - 80 }, + ) catch return error.GeneratedPdfTooLarge; + } + stream.writeAll("BT /F1 28 Tf 40 40 Td (bottommost) Tj ET\n") catch return error.GeneratedPdfTooLarge; + const content = stream.buffered(); + try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); + try beginPdfObject(bytes, offsets, 1); + try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 2); + try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 3); + try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 9000] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 4); + try bytes.print("<< /Length {d} >>\nstream\n", .{content.len}); + try bytes.appendSlice(content); + try bytes.appendSlice("endstream\nendobj\n"); + try beginPdfObject(bytes, offsets, 5); + try bytes.appendSlice("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); + try finishGeneratedPdf(bytes, offsets); +} + +/// TEST-ONLY: one page fifteen times as tall as it is wide (`tall_test_page`), +/// "Row N" every 100 pt from the top and "bottommost" at its foot. +pub fn makeTallTestPdf(gpa: std.mem.Allocator) ![]u8 { + var offsets: [6]usize = undefined; + return allocateGeneratedPdf(gpa, &offsets, {}, emitTallPdf); +} + +test "a tall page rasterizes at the size it is shown, however many max_dimensions tall" { + const bytes = try makeTallTestPdf(std.testing.allocator); + defer std.testing.allocator.free(bytes); + var document = try Document.openBytes(bytes); + defer document.deinit(); + const size = try document.pageSize(0); + try std.testing.expectEqual(tall_test_page.width, size.width); + try std.testing.expectEqual(tall_test_page.height, size.height); + + // Shown 960 px wide (fit width): 960 px a row, 14400 rows, though that is + // far past max_dimension. The old clamp of the longest side made this + // 273 x 4096, drawn 3.5 times too large. + const shown: RenderRequest = .{ .dpi = 192, .minimum_width = 960, .minimum_height = 600, .max_dimension = 4096, .display_width = 960 }; + const tall = try document.measureRenderAt(0, shown); + try std.testing.expectEqual(@as(usize, 960), tall.width); + try std.testing.expectEqual(@as(usize, 14400), tall.height); + // Kitty's 96 dpi, as any page has it, not 1200 rows' worth. + const kitty = try document.measureRenderAt(0, .{ .dpi = 96, .max_dimension = 1200, .display_width = 944 }); + try std.testing.expectEqual(@as(usize, 800), kitty.width); + try std.testing.expectEqual(@as(usize, 12000), kitty.height); + // The dpi never takes it past what is shown: that would be bands of + // pixels nobody sees. + const small = try document.measureRenderAt(0, .{ .dpi = 192, .max_dimension = 4096, .display_width = 300 }); + try std.testing.expectEqual(@as(usize, 300), small.width); + // Shown whole (fit height), the height cap holds: the page is 600 px + // tall on screen and 4096 rows is already more. + const whole = try document.measureRenderAt(0, .{ .dpi = 192, .minimum_width = 960, .minimum_height = 600, .max_dimension = 4096, .display_height = 600 }); + try std.testing.expectEqual(@as(usize, 4096), whole.height); + // Without a display size, the old budget. + const blind = try document.measureRenderAt(0, .{ .dpi = 192, .minimum_width = 960, .max_dimension = 4096 }); + try std.testing.expectEqual(@as(usize, 4096), blind.height); + // A row is never longer than max_dimension, whatever is shown. + const wide = try document.measureRenderAt(0, .{ .dpi = 192, .minimum_width = 10_000, .max_dimension = 4096, .display_width = 10_000 }); + try std.testing.expectEqual(@as(usize, 4096), wide.width); + // One buffer is bounded even when the page is not: a whole render of a + // page past it is refused, a band of it is not. + const poster: RenderRequest = .{ .dpi = 72, .minimum_width = 4096, .max_dimension = 4096, .display_width = 4096 }; + const huge = try document.measureRenderAt(0, poster); + try std.testing.expect(huge.len > max_owned_raster_bytes); + try std.testing.expectError(error.PixmapTooLarge, document.renderAt(std.testing.allocator, 0, poster)); + const band = huge.band(huge.height - 100, 100); + const rows = try std.testing.allocator.alloc(u8, band.len); + defer std.testing.allocator.free(rows); + try document.renderIntoAt(0, poster, huge, band, &.{}, rows); + try expectOpaque(rows); +} + test "PDF links own URIs across page eviction and preserve internal coordinates" { const gpa = std.testing.allocator; start(gpa); @@ -1830,6 +1944,8 @@ test "RGBA allocation and buffer-validation failures leave the document renderab default_render_request.minimum_width, default_render_request.minimum_height, default_render_request.max_dimension, + 0, + 0, &raw_layout, )); const layout = try checkedRasterLayout(raw_layout); @@ -1844,6 +1960,8 @@ test "RGBA allocation and buffer-validation failures leave the document renderab default_render_request.minimum_width, default_render_request.minimum_height, default_render_request.max_dimension, + 0, + 0, null, 0, scratch.ptr, diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index 2c723dbb..6fdab46c 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -481,6 +481,8 @@ pardes_pdf_render_geometry( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, fz_matrix *ctm_out, fz_irect *bbox_out) { @@ -488,23 +490,38 @@ pardes_pdf_render_geometry( fz_rect bounds; float page_width; float page_height; - float longest; float scale; + float shown = 0.0f; pardes_pdf_cache_page(document, page_number, 0); bounds = document->cached_bounds; page_width = bounds.x1 - bounds.x0; page_height = bounds.y1 - bounds.y0; - longest = fmaxf(page_width, page_height); - if (!(longest > 0.0f)) + if (!(page_width > 0.0f) || !(page_height > 0.0f)) fz_throw(document->ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds"); scale = (float)dpi / 72.0f; if (minimum_width > 0) scale = fmaxf(scale, (float)minimum_width / page_width); if (minimum_height > 0) scale = fmaxf(scale, (float)minimum_height / page_height); - if (longest * scale > (float)max_dimension) - scale = (float)max_dimension / longest; + /* + * A row of the raster is never longer than max_dimension: every band of + * the page carries whole rows. The height is held to it too, as the + * budget of one whole-page raster, but never below the scale the page is + * shown at: a page taller than that (a poster, a long web capture) is + * drawn in bands at the display's own resolution (pdf_view.zig) instead + * of being squeezed into max_dimension rows and blown up again, blurred. + */ + if (page_width * scale > (float)max_dimension) + scale = (float)max_dimension / page_width; + if (display_width > 0) + shown = fmaxf(shown, (float)display_width / page_width); + if (display_height > 0) + shown = fmaxf(shown, (float)display_height / page_height); + if (page_height * scale > (float)max_dimension) + scale = fminf(scale, fmaxf((float)max_dimension / page_height, shown)); + if (page_height * scale > (float)PARDES_PDF_MAX_RASTER_ROWS) + scale = (float)PARDES_PDF_MAX_RASTER_ROWS / page_height; /* * Keep this identical to fz_new_pixmap_from_page/display_list: transform @@ -526,6 +543,8 @@ pardes_pdf_measure_render( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, pardes_pdf_raster_layout *out) { fz_context *ctx; @@ -541,14 +560,16 @@ pardes_pdf_measure_render( memset(out, 0, sizeof(*out)); if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || - minimum_width < 0 || minimum_height < 0 || max_dimension < 1) + minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + display_width < 0 || display_height < 0) return PARDES_PDF_ERROR; ctx = document->ctx; fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, - minimum_width, minimum_height, max_dimension, &ctm, &bbox); + minimum_width, minimum_height, max_dimension, + display_width, display_height, &ctm, &bbox); width = fz_irect_width(bbox); height = fz_irect_height(bbox); if (width > INT_MAX / 4) @@ -629,6 +650,8 @@ pardes_pdf_render_into( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, unsigned char *samples, @@ -649,6 +672,7 @@ pardes_pdf_render_into( if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + display_width < 0 || display_height < 0 || (highlight_count != 0 && highlights == NULL) || highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL || width < 1 || height < 1 || stride < 1 || width > INT_MAX / 4 || @@ -664,7 +688,8 @@ pardes_pdf_render_into( fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, - minimum_width, minimum_height, max_dimension, &ctm, &bbox); + minimum_width, minimum_height, max_dimension, + display_width, display_height, &ctm, &bbox); if (fz_irect_width(bbox) != width || fz_irect_height(bbox) != height) fz_throw(ctx, FZ_ERROR_ARGUMENT, @@ -676,9 +701,13 @@ pardes_pdf_render_into( * discards everything outside these rows. The rows are NOT always * bit-identical to the full page's: rows at the band's edges, and a * resampled image anywhere in it, can differ (measured on - * docs/registry.pdf and docs/design.pdf). Good enough for a band that - * is only shown while flinging; never use a band to patch rows into a - * whole-page raster -- `pardes_pdf_paint_highlights` exists for that. + * docs/registry.pdf and docs/design.pdf), and an edge crossing the + * clip is stepped from a different start along its whole length, + * a level or two off. Good enough for a band shown while flinging, + * and for the chunks of a page too tall to render whole (pdf_view.zig + * renders those past their edges and keeps the middle); never use a + * band to patch rows into a whole-page raster -- + * `pardes_pdf_paint_highlights` exists for that. */ bbox.y0 += band_y; bbox.y1 = bbox.y0 + band_height; @@ -708,10 +737,21 @@ pardes_pdf_render_into( if (document->cached_display_list != NULL || document->display_list_candidate_page_number == page_number) { + /* + * Only the nodes that reach the band: a band of a tall page + * skips the rest of the page instead of handing every node to + * the draw device to clip away. The scissor is in page space + * (the list runs untransformed under the device's CTM), a pixel + * wider than the band all round. Culling never changes a pixel: + * a node outside it draws nothing here. + */ + fz_rect scissor = fz_rect_from_irect(bbox); + scissor.x0 -= 1; scissor.y0 -= 1; scissor.x1 += 1; scissor.y1 += 1; + scissor = fz_transform_rect(scissor, fz_invert_matrix(ctm)); pardes_pdf_cache_display_list(document); device = fz_new_draw_device(ctx, ctm, pixmap); fz_run_display_list(ctx, document->cached_display_list, device, - fz_identity, fz_infinite_rect, NULL); + fz_identity, scissor, NULL); } else { device = fz_new_draw_device(ctx, ctm, pixmap); fz_run_page(ctx, document->cached_page, device, fz_identity, NULL); @@ -767,6 +807,8 @@ pardes_pdf_paint_highlights( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, unsigned char *samples, @@ -786,6 +828,7 @@ pardes_pdf_paint_highlights( if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + display_width < 0 || display_height < 0 || (highlight_count != 0 && highlights == NULL) || highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL || width < 1 || height < 1 || width > INT_MAX / 4 || stride != width * 4 || @@ -802,7 +845,8 @@ pardes_pdf_paint_highlights( fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, - minimum_width, minimum_height, max_dimension, &ctm, &bbox); + minimum_width, minimum_height, max_dimension, + display_width, display_height, &ctm, &bbox); if (fz_irect_width(bbox) != width || fz_irect_height(bbox) != height) fz_throw(ctx, FZ_ERROR_ARGUMENT, "PDF raster layout changed between measure and paint"); @@ -835,6 +879,8 @@ pardes_pdf_highlight_rows( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, int *row_start, @@ -853,6 +899,7 @@ pardes_pdf_highlight_rows( if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + display_width < 0 || display_height < 0 || (highlight_count != 0 && highlights == NULL)) return PARDES_PDF_ERROR; @@ -860,7 +907,8 @@ pardes_pdf_highlight_rows( fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, - minimum_width, minimum_height, max_dimension, &ctm, &bbox); + minimum_width, minimum_height, max_dimension, + display_width, display_height, &ctm, &bbox); for (i = 0; i < highlight_count; ++i) { fz_rect r; if (highlights[i].rgba[3] == 0 || diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index e3514437..29d87bff 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -183,7 +183,10 @@ enum { PARDES_PDF_MAX_OUTLINE_ITEMS = 8192, PARDES_PDF_MAX_OUTLINE_DEPTH = 64, PARDES_PDF_MAX_OUTLINE_BYTES = 4 * 1024 * 1024, - PARDES_PDF_MAX_LINK_URI_BYTES = 64 * 1024 + PARDES_PDF_MAX_LINK_URI_BYTES = 64 * 1024, + + /* No page raster is taller than this, whatever it is shown at. */ + PARDES_PDF_MAX_RASTER_ROWS = 1 << 20 }; pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count); @@ -199,7 +202,11 @@ int pardes_pdf_get_page_size( /* * Start at dpi, raise the uniform scale until both optional minimum pixel - * dimensions are met, then clamp the page's longest side to max_dimension. + * dimensions are met, then clamp the page's width to max_dimension, and its + * height too unless that would draw it smaller than the optional display size + * (the width or height it is shown at): a tall page is then as tall as it is + * shown, and its owner renders it in bands. Every function below takes the + * same request and computes the same raster from it. * Measuring may populate the page cache, but never advances adaptive display * list promotion; only a successful render_into does that. */ @@ -210,6 +217,8 @@ int pardes_pdf_measure_render( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, pardes_pdf_raster_layout *out ); @@ -233,6 +242,8 @@ int pardes_pdf_render_into( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, unsigned char *samples, @@ -257,6 +268,8 @@ int pardes_pdf_paint_highlights( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, unsigned char *samples, @@ -280,6 +293,8 @@ int pardes_pdf_highlight_rows( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, int *row_start, diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 6c137202..09c06356 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -39,6 +39,20 @@ const raster_max = 256; const raster_spare = 4; pub const page_gap_px: u32 = 8; const band_grain: usize = 64; +/// The tallest raster one buffer carries: within every GPU's texture size +/// and Kitty's 10000-pixel image limit (kitty and ghostty both). A page +/// taller than this, or than `pdf.max_owned_raster_bytes` allows, is BANDED: +/// it is held as the rows around what is on screen and rendered in chunks. +const max_band_rows: usize = 8192; +/// A banded page is rendered in chunks of this many rows, each from its own +/// render of the chunk and `chunk_overlap` rows of the page beyond both of +/// its edges, which are thrown away. A row of the page therefore always comes +/// from the same render, however the reader scrolled to it, and never from +/// the edge of one: MuPDF's rows at the edge of a clip can differ from the +/// same rows rendered whole (see `pardes_pdf_render_into`), which, butted +/// against the next chunk, would be a seam. +const chunk_rows: usize = 512; +const chunk_overlap: usize = 32; pub const FitMode = if (enabled) enum { width, height } else void; pub const TintMode = if (enabled) pdf.TintMode else void; @@ -802,13 +816,18 @@ const VisibleRows = struct { pixel_offset_y: f32, }; -pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest { +/// The raster a pane asks for. The page is shown `viewport.pixel_w` wide +/// (fit width) or `pixel_h` tall (fit height), and the raster is never +/// smaller than that, however tall the page: see `pdf.RenderRequest`. +pub fn renderRequest(viewport: Viewport, policy: RasterPolicy, fit: FitMode) RenderRequest { if (comptime !enabled) return; return .{ .dpi = policy.dpi, .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0, .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0, .max_dimension = policy.max_dimension, + .display_width = if (fit == .width) viewport.pixel_w else 0, + .display_height = if (fit == .height) viewport.pixel_h else 0, }; } @@ -1052,13 +1071,34 @@ fn flinging(state: *const State, viewport: Viewport) bool { return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h)); } +/// The most rows one buffer of this page holds. +fn bandCap(shape: pdf.Raster) usize { + return @min(max_band_rows, pdf.max_owned_raster_bytes / @max(shape.stride, 1)); +} + +/// A page too tall for one buffer: see `max_band_rows`. +pub fn isBanded(shape: pdf.Raster) bool { + if (comptime !enabled) return false; + return shape.height > bandCap(shape); +} + +/// What a banded page's buffer is asked to hold. `target` is what a render +/// covers: the rows on screen and a screenful either side, so scrolling finds +/// the next rows ready (none while flinging, which only wants this frame's). +/// `keep` is what the buffer must still cover to be kept: half a screenful +/// either side, so a render happens once per half screenful scrolled, not on +/// every frame. +const Reach = enum { target, keep }; + fn wantedBand( state: *const State, viewport: Viewport, page: usize, shape: pdf.Raster, is_flinging: bool, + reach: Reach, ) pdf.Raster.Band { + if (isBanded(shape)) return chunkBand(state, viewport, page, shape, is_flinging, reach); if (!is_flinging) return shape.wholePage(); const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse return shape.wholePage(); @@ -1068,6 +1108,117 @@ fn wantedBand( return shape.band(first, last * band_grain - first); } +fn chunkBand( + state: *const State, + viewport: Viewport, + page: usize, + shape: pdf.Raster, + is_flinging: bool, + reach: Reach, +) pdf.Raster.Band { + const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse + return shape.band(0, chunk_rows); + const seen: usize = rows.y1 - rows.y0; + const margin: usize = if (is_flinging) 0 else switch (reach) { + .target => seen, + .keep => seen / 2, + }; + const top = @as(usize, rows.y0) -| margin; + const bottom = @min(shape.height, @as(usize, rows.y1) + margin); + if (reach == .keep) return shape.band(top, bottom - top); + var first = top / chunk_rows * chunk_rows; + var last = @min(shape.height, (bottom + chunk_rows - 1) / chunk_rows * chunk_rows); + const cap = bandCap(shape) / chunk_rows * chunk_rows; + if (last - first > cap) { + // The margins give way: what is on screen always fits. + first = @as(usize, rows.y0) / chunk_rows * chunk_rows; + last = @min(shape.height, first + cap); + } + return shape.band(first, last - first); +} + +/// Fill `fresh` with the rows `want` of a banded page. Chunks the slot +/// already holds, under this request and tint and with these highlights +/// (`reuse`), are copied; the rest are rendered (`renderChunk`), then +/// highlighted and tinted as `reconcile` does a whole page. `slot.clean` +/// becomes the new rows' clean copy when `keep_clean`. +fn fillBanded( + state: *State, + gpa: std.mem.Allocator, + slot: *const Raster, + request: RenderRequest, + shape: pdf.Raster, + want: pdf.Raster.Band, + highlights: []const Highlight, + tint_key: TintKey, + fresh: []u8, + keep_clean: bool, + reuse: bool, + clean_out: *[]u8, +) bool { + const tz = tracy.zone(@src(), "pdf.fill_banded"); + defer tz.end(); + const stride = shape.stride; + const clean: []u8 = if (keep_clean) gpa.alloc(u8, fresh.len) catch return false else &.{}; + var filled = false; + defer if (!filled and clean.len > 0) gpa.free(clean); + const held = reuse and slot.rgba.len == slot.band_h * stride and + (!keep_clean or slot.clean.len == slot.rgba.len); + var scratch: []u8 = &.{}; + defer if (scratch.len > 0) gpa.free(scratch); + const end = want.y + want.height; + var y = want.y; + while (y < end) { + const rows = @min(chunk_rows - y % chunk_rows, end - y); + const at = (y - want.y) * stride; + const out = fresh[at..][0 .. rows * stride]; + if (held and slot.band_y <= y and y + rows <= slot.band_y + slot.band_h) { + const from = (y - slot.band_y) * stride; + @memcpy(out, slot.rgba[from..][0..out.len]); + if (keep_clean) @memcpy(clean[at..][0..out.len], slot.clean[from..][0..out.len]); + } else { + if (scratch.len == 0) + scratch = gpa.alloc(u8, (chunk_rows + 2 * chunk_overlap) * stride) catch return false; + const target = if (keep_clean) clean[at..][0..out.len] else out; + renderChunk(state, request, shape, slot.page, y, if (keep_clean) &.{} else highlights, target, scratch) catch + return false; + if (keep_clean) { + @memcpy(out, target); + state.document.paintHighlightsAt(slot.page, request, shape, shape.band(y, rows), highlights, out) catch + return false; + } + pdf.tintRgba(out, tint_key.mode, tint_key.colors) catch return false; + } + y += rows; + } + filled = true; + clean_out.* = clean; + return true; +} + +/// The `out.len / stride` rows of a banded page from row `y` (a chunk or +/// its end), cut from a render reaching `chunk_overlap` rows past both. +fn renderChunk( + state: *State, + request: RenderRequest, + shape: pdf.Raster, + page: usize, + y: usize, + highlights: []const Highlight, + out: []u8, + scratch: []u8, +) !void { + const tz = tracy.zone(@src(), "pdf.render_chunk"); + defer tz.end(); + const rows = out.len / shape.stride; + const top = y -| chunk_overlap; + const bottom = @min(shape.height, y + rows + chunk_overlap); + const band = shape.band(top, bottom - top); + const buf = scratch[0..band.len]; + try state.document.renderIntoAtWithPaper(page, request, shape, band, highlights, buf, pardes.platform == .macos); + @memcpy(out, buf[(y - top) * shape.stride ..][0..out.len]); +} + fn reconcile( state: *State, gpa: std.mem.Allocator, @@ -1113,10 +1264,13 @@ fn reconcile( slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or slot.rgba.len == 0 or slot.band_h == 0; const uncovered = !base_stale and uncovered: { - const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging); + const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging, .keep); break :uncovered slot.band_y > want.y or slot.band_y + slot.band_h < want.y + want.height; }; + // A banded page moving on keeps the rows it has, if they are exactly + // what a render would make now. + const reuse = !base_stale and slot.baked_valid and sameHighlights(slot.baked, page_highlights); if (!base_stale and !uncovered) { if (slot.tried and slot.decorated == decorated and !(highlights.live and !sameHighlights(slot.baked, page_highlights))) continue; @@ -1140,14 +1294,21 @@ fn reconcile( break :shape state.document.measureRenderAt(page, request) catch null; }; const shape = shape_or_null orelse continue; - const want = wantedBand(state, viewport, page, shape, is_flinging); + const want = wantedBand(state, viewport, page, shape, is_flinging, .target); const fresh = state.claimRgba(gpa, want.len) orelse continue; - const filled = filled: { + // A highlighted page keeps its clean rows for repaints. + const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0); + const filled = if (isBanded(shape)) banded: { + var clean: []u8 = &.{}; + if (!fillBanded(state, gpa, slot, request, shape, want, page_highlights, tint_key, fresh, keep_clean, reuse, &clean)) + break :banded false; + if (slot.clean.len > 0) gpa.free(slot.clean); + slot.clean = clean; + break :banded true; + } else filled: { { const tz_render = tracy.zone(@src(), "pdf.render_into"); defer tz_render.end(); - // A highlighted page keeps its clean rows for repaints. - const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0); state.document.renderIntoAtWithPaper( page, request, @@ -1330,7 +1491,7 @@ pub fn renderFrame( ensureLayout(state, viewport); state.resolveSearch(gpa); const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{}; - const request = renderRequest(viewport, policy); + const request = renderRequest(viewport, policy, state.fit); var visible = visiblePages(state, viewport); reconcile(state, gpa, request, tint_key, highlights, visible, viewport); diff --git a/src/tagline.zig b/src/tagline.zig index dc7d10d4..9bd49eef 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -389,7 +389,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { t.normal.clear(); } -/// A dump's tag tail, what was typed after the name: a dump from 0.0.4 on +/// A dump's tag tail, what was typed after the name: a dump from 0.4 on /// writes it, and an older one's saved tag is not read. pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { const tail = src.tag_tail orelse return; @@ -479,6 +479,22 @@ def run_file(binary): assert run(client, term, b'fi\n') == b'error not run\n' # a syntax error runs nothing assert run(client, term, b"echo 'open\n") == b'error not run\n' # nor does an open quote assert run(client, term, b'true\n') == b'exit 0\n' # ...and the shell is clear after + # A paging command run through pty/run does not wait in less: the + # terminal's PAGER is `pardes -`, and its text is a +Pager pane. + if shutil.which('git'): + repo = root / 'pagedrepo' + repo.mkdir() + git = ['git', '-C', str(repo), '-c', 'user.name=p', '-c', 'user.email=p@x'] + subprocess.run(git + ['init', '-q'], check=True, env=dict(os.environ, HOME=str(root))) + (repo / 'f').write_text('x\n') + subprocess.run(git + ['add', 'f'], check=True, env=dict(os.environ, HOME=str(root))) + subprocess.run(git + ['commit', '-qm', 'paged-commit-subject'], check=True, env=dict(os.environ, HOME=str(root))) + assert run(client, term, b'git -C pagedrepo log\n').startswith(b'exit 0\n') + pager = [r.split()[0].decode() for r in client.read('/index').splitlines() if r.split()[3:4] == [f'{repo}/+Pager'.encode()]] # git -C runs it in the repo + assert len(pager) == 1, client.read('/index') + assert b'paged-commit-subject' in client.read(f'/pane/{pager[0]}/body') + assert b'\x1b' not in client.read(f'/pane/{pager[0]}/body') + client.remove(f'/pane/{pager[0]}') # A pty is not given one row (it loses the prompt's input mark, # and the shell would read busy for ever): refused, and said. try: @@ -1306,6 +1322,32 @@ def test(binary, quic=False): # opened is deleted, as an $EDITOR must; one on a file already open # waits on that pane; a session that goes away ends it with 1. with session(binary, root, 'wait') as (client, address): + # A command run from a pane's tag gets acme's $winid, that pane; + # one from the workspace's tag, none. + def command_body(node, line): + client.write(node, line) + serial = int(client.read(node).split()[-1]) + deadline = time.monotonic() + 10 + while True: + log = client.read('/log').decode() + ran = log.rfind(f'\nrun {serial} ') + if ran >= 0 and f'\nexit {serial} ' in log[ran:]: + body = client.read(f'/pane/{serial}/body').decode().split('% echo winid')[-1] + client.remove(f'/pane/{serial}') + return body + assert time.monotonic() < deadline, log[-400:] + time.sleep(.05) + first_pane = min(int(row.split()[0]) for row in client.read('/index').splitlines()) + assert f'winid={first_pane}\n' in command_body(f'/pane/{first_pane}/tagexec', b'echo winid=$winid\n') + assert 'winid=\n' in command_body('/tagexec', b'echo winid=$winid\n') + # A second --detach of a name a live session holds: said, exit 1, + # no Zig error trace. + second_env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')} + second_env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root)) + second = subprocess.run([binary, '--detach=wait'], cwd=root, env=second_env, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=20) + assert second.returncode == 1, second + assert second.stderr == b'pardes: a session named wait is already running\n', second.stderr status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines()) env = {k: v for k, v in os.environ.items() if k != 'PARDES_FORWARD_LOOK'} env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_PID=status['pid']) @@ -1395,6 +1437,30 @@ def test(binary, quic=False): # A pane address the session has not is refused in words, exit # 1, no pane made for a file of that name; one it has is looked # at. A name with a newline is said in words, not an error name. + # `pardes -` (a terminal's PAGER): stdin, its escapes stripped, in a + # +Pager pane of the session, clean, and back at once. + fed = subprocess.run([binary, '-'], cwd=root, env=env, input=b'\x1b[1mbold\x1b[0m text\n', + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10) + assert fed.returncode == 0, fed.stderr + made_now, = serials() - before + assert client.read(f'/pane/{made_now}/name') == f'{root}/+Pager\n'.encode() + assert client.read(f'/pane/{made_now}/body') == b'bold text\n' + row = [r for r in client.read('/index').splitlines() if r.split()[0] == str(made_now).encode()][0] + assert row.split()[2] == b'0', row # clean: nothing to ask at Exit + client.remove(f'/pane/{made_now}') + # A new name in a directory there but not writable: refused now, + # not a pane that only fails at Save. + shut = root / 'shut-dir' + shut.mkdir() + shut.chmod(0o500) + try: + refused = subprocess.run([binary, 'shut-dir/new.txt'], cwd=root, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10) + assert refused.returncode == 1, refused + assert b'may not be read or written' in refused.stderr, refused.stderr + assert serials() == before, (serials(), before) + finally: + shut.chmod(0o755) for word, said in (('@p999:1', b'pardes: @p999:1: this session has no such pane'), ('two\nlines.txt', b'a file name is one line')): refused = subprocess.run([binary, word], cwd=root, env=env, @@ -1411,6 +1477,30 @@ def test(binary, quic=False): assert orphan.poll() is None assert orphan.wait(timeout=5) == 1 + # `pardes -` outside a session: an editor whose first pane is a + # +Pager of stdin, as `vim -`; here a detached one, to read it. + outside_env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')} + outside_env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), PARDES_NOTIME='1') + boot = subprocess.Popen([binary, '--detach=stdinboot', '-'], cwd=root, env=outside_env, + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + start_new_session=True) + try: + boot.stdin.write(b'\x1b[32mfrom stdin\x1b[m\nsecond\n') + boot.stdin.close() + sock = root / 'pardes-9p-stdinboot.sock' + deadline = time.monotonic() + 10 + while not sock.exists(): + assert boot.poll() is None, boot.stderr.read() + assert time.monotonic() < deadline, 'the stdin boot never listened' + time.sleep(.05) + with Client(str(sock)) as booted: + first = booted.read('/index').split(b'\n')[0].split() + assert first[3] == f'{root}/+Pager'.encode(), first + assert booted.read(f'/pane/{first[0].decode()}/body') == b'from stdin\nsecond\n' + finally: + boot.terminate() + boot.wait(timeout=10) + # Killed with SIGTERM or SIGHUP, a tty editor puts its terminal # back (main screen, cooked mode) before it dies: a nested one # killed in a pane leaves that pane usable. diff --git a/test/pdf.zig b/test/pdf.zig index fe084cc1..6864f247 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -730,6 +730,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { const request = panes.Pdf.renderRequest( panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport, pdf_raster_policy, + pane.pdf.?.fit, ); try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi); try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension); @@ -1119,6 +1120,171 @@ test "a fling's banded pages show the reader exactly what whole pages would" { } } +/// MuPDF clips the edges of a path to the pixmap, and an edge that crosses a +/// band's clip is stepped from a different start: along its whole length in +/// the band, its antialiased pixels can be a level or two off the whole +/// page's. Never more. +const band_tolerance: u8 = 4; + +fn expectNearRows(expected: []const u8, got: []const u8) !void { + try std.testing.expectEqual(expected.len, got.len); + var worst: u8 = 0; + for (expected, got) |a, b| worst = @max(worst, if (a > b) a - b else b - a); + if (worst > band_tolerance) { + std.debug.print("band rows are {d} levels off the whole page's\n", .{worst}); + return error.TestExpectedEqual; + } +} + +test "a tall page is drawn at the display's resolution, in bands that are the whole page's rows" { + if (!pdf_enabled or platform == .web) return; + + // The page is fifteen A4 heights tall. Squeezed whole into the raster + // budget it was a third as wide as the pane and blown up, blurred; it is + // drawn instead at the pane's own resolution, a band around the screen at + // a time, and every band row must be the row a whole-page render makes + // (to `band_tolerance`), and the same row however it was scrolled to. + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const bytes = try pdf_impl.makeTallTestPdf(gpa); + defer gpa.free(bytes); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "tall.pdf", .data = bytes }); + var dir_buf: [4096]u8 = undefined; + const dir_len = try tmp.dir.realPath(std.testing.io, &dir_buf); + const path = try std.fmt.allocPrint(gpa, "{s}/tall.pdf", .{dir_buf[0..dir_len]}); + defer gpa.free(path); + const p = try Pardes.init(gpa, .{ .file = path, .cols = 120, .rows = 40 }); + defer p.deinit(); + p.native_images = true; + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + + const viewport = panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport; + const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy, pv.fit); + const shape = try pv.document.measureRenderAt(0, request); + if (pdf_raster_policy.match_viewport) { + // The rendered scale IS the screen's: one raster pixel per pixel of + // the pane's width, so nothing is scaled up. + try std.testing.expectEqual(@as(usize, viewport.pixel_w), shape.width); + } else { + // Kitty's policy is a dpi, kept low for the wire; the tall page gets + // it as every other page does, not a quarter of it. + try std.testing.expectEqual( + @as(usize, @intFromFloat(pdf_impl.tall_test_page.width)) * pdf_raster_policy.dpi / 72, + shape.width, + ); + } + try std.testing.expect(panes.Pdf.isBanded(shape)); + + // The reference: the whole page in one render, before highlights and + // tint, and tinted as the pane tints. + const plain = try gpa.alloc(u8, shape.len); + defer gpa.free(plain); + try pv.document.renderIntoAt(0, request, shape, shape.wholePage(), &.{}, plain); + const whole = try gpa.dupe(u8, plain); + defer gpa.free(whole); + try pdf_impl.tintRgba(whole, pv.tint, panes.Pdf.tintColors(p)); + + const max_scroll = @as(f64, @floatFromInt(pv.document_height - viewport.pixel_h)); + var middle: []u8 = &.{}; + defer gpa.free(middle); + var middle_y: usize = 0; + for ([_]f64{ 0, @round(max_scroll / 2), max_scroll }, 0..) |at, step| { + _ = panes.Pdf.scrollPane(p, pane, at - pv.document_scroll_y); + pv.scroll_travel = 0; // read, not flung + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), surface.nimages); + const place = surface.images[0].?; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = panes.Pdf.rasterForPage(pv, 0) orelse return error.MissingPdfRaster; + // Bounded: a band, never the page, and within one buffer's budget. + try std.testing.expectEqual(shape.width, place.iw); + try std.testing.expect(raster.band_h < raster.ih); + try std.testing.expect(place.rgba.len <= pdf_impl.max_owned_raster_bytes); + // Prefetched: a screenful beyond what is shown, both ways, where the + // page goes on. + const seen_top = raster.band_y + geometry.src.y; + const seen_end = seen_top + geometry.src.h; + try std.testing.expect(raster.band_y <= seen_top -| geometry.src.h); + try std.testing.expect(raster.band_y + raster.band_h >= @min(raster.ih, seen_end + geometry.src.h)); + // ...and every row of the band is the whole page's row. + const stride = shape.stride; + try expectNearRows(whole[raster.band_y * stride ..][0 .. raster.band_h * stride], place.rgba); + if (step == 1) { + middle = try gpa.dupe(u8, place.rgba); + middle_y = raster.band_y; + } + } + + // Straight back to the middle from the foot: the rows rendered afresh + // are the very rows rendered on the way down, so no seam moves with the + // path a reader took. + _ = panes.Pdf.scrollPane(p, pane, @round(max_scroll / 2) - pv.document_scroll_y); + pv.scroll_travel = 0; + _ = frame.reset(.retain_capacity); + const again = (try p.render(frame.allocator())).images[0].?; + const again_y = panes.Pdf.rasterForPage(pv, 0).?.band_y; + const stride = shape.stride; + const common_top = @max(middle_y, again_y); + const common_end = @min(middle_y + middle.len / stride, again_y + again.rgba.len / stride); + try std.testing.expect(common_end > common_top); + try std.testing.expectEqualSlices( + u8, + middle[(common_top - middle_y) * stride .. (common_end - middle_y) * stride], + again.rgba[(common_top - again_y) * stride .. (common_end - again_y) * stride], + ); + _ = panes.Pdf.scrollPane(p, pane, max_scroll - pv.document_scroll_y); + pv.scroll_travel = 0; + _ = frame.reset(.retain_capacity); + const foot = try gpa.dupe(u8, (try p.render(frame.allocator())).images[0].?.rgba); + defer gpa.free(foot); + + // At the foot of the page, a search hit: its highlight lands on exactly + // the rows and pixels it would on the whole page, and a pointer over the + // word finds the word. + try pv.setSearchQuery(gpa, "bottommost"); + _ = frame.reset(.retain_capacity); + const lit = try p.render(frame.allocator()); + const place = lit.images[0].?; + const raster = panes.Pdf.rasterForPage(pv, 0) orelse return error.MissingPdfRaster; + const highlights = try panes.Pdf.buildHighlights(pv, frame.allocator(), panes.Pdf.highlightInput(p, 0, pane)); + const marks = highlights.forPage(0); + try std.testing.expect(marks.len > 0); + const rows = try pv.document.highlightRows(0, request, marks); + try std.testing.expect(rows.start >= raster.band_y and rows.end <= raster.band_y + raster.band_h); + // Exactly the highlight's rows changed, and nothing else... + try std.testing.expectEqual(foot.len, place.rgba.len); + const from = (rows.start - raster.band_y) * stride; + const to = (rows.end - raster.band_y) * stride; + try std.testing.expectEqualSlices(u8, foot[0..from], place.rgba[0..from]); + try std.testing.expectEqualSlices(u8, foot[to..], place.rgba[to..]); + try std.testing.expect(!std.mem.eql(u8, foot[from..to], place.rgba[from..to])); + // ...to what the whole page painted with them is. + const expected = try gpa.dupe(u8, plain[rows.start * stride .. rows.end * stride]); + defer gpa.free(expected); + try pv.document.paintHighlightsAt(0, request, shape, shape.band(rows.start, rows.end - rows.start), marks, expected); + try pdf_impl.tintRgba(expected, pv.tint, panes.Pdf.tintColors(p)); + try expectNearRows(expected, place.rgba[from..to]); + + const quad = pv.search_results.?.quads[0].quad; + const left = @min(quad.ul.x, quad.ll.x); + const right = @max(quad.ur.x, quad.lr.x); + const top = @min(quad.ul.y, quad.ur.y); + const bottom = @max(quad.ll.y, quad.lr.y); + const rect = p.rects[0]; + var inside: usize = 0; + for (rect.y..rect.y + rect.h) |row| for (rect.x..rect.x + rect.w) |col| { + const point = panes.Pdf.panePointAtPage(p, pane, 0, @intCast(col), @intCast(row), false) orelse continue; + if (point.x >= left and point.x <= right and point.y >= top and point.y <= bottom) inside += 1; + }; + try std.testing.expect(inside > 0); +} + /// The pixels a backend samples out of one placement: the source rectangle, /// row by row, at the texture's own stride. Test-only, and the one operation /// that makes "same picture" mean something when the textures differ in shape. @@ -1306,7 +1472,7 @@ test "PDF continuous strip renders every intersecting short page" { const viewport = panes.Pdf.ensurePaneLayout(p, pane).?; const visible = panes.Pdf.visiblePages(pv, viewport); try std.testing.expectEqual(pv.page_count, visible.len); - const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy); + const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy, pv.fit); try std.testing.expect(pv.page_count <= pv.rasters.len); for (0..pv.page_count) |page| { const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4); diff --git a/test/pdf_scroll_bench.zig b/test/pdf_scroll_bench.zig index a4511dbe..166fa809 100644 --- a/test/pdf_scroll_bench.zig +++ b/test/pdf_scroll_bench.zig @@ -4,6 +4,7 @@ //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --json //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --warmup 1 --reps 15 //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --path other.pdf +//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --cell 16x32 a 2x display //! zig build pdf-scroll-bench -Doptimize=Debug -- --reps 1 the diagnosis //! zig build pdf-scroll-bench -Dtracy=~/05-genizah/tracy the zones //! @@ -62,7 +63,7 @@ const gpa = std.heap.smp_allocator; // is what the tty shell actually ships. const bench_cols: u16 = 120; const bench_rows: u16 = 40; -const bench_cell_pixels: pardes.CellPixels = .{ .w = 8, .h = 16 }; +const default_cell_pixels: pardes.CellPixels = .{ .w = 8, .h = 16 }; /// config.wheel_rows is one cell per notch, so a notch is `cell_pixels.h` /// display pixels. 192 notches is ~3072px — between two and three fit-width @@ -74,7 +75,7 @@ const slow_ticks_per_frame: usize = 1; /// The control row is a fixed wall of frames rather than a whole-document /// traversal: one notch per frame over a real document is tens of thousands of /// frames, and 240 already crosses several page boundaries at reading speed. -const slow_frames: usize = 240; +const default_slow_frames: usize = 240; /// Guard against a pathological document turning one row into a multi-minute /// run. Deterministic either way: the frame count is derived from layout. const max_frames: usize = 2048; @@ -91,6 +92,10 @@ const Config = struct { /// Override `max_frames` for one run: a 5000-page manual is 2000 frames of /// travel per round, which is a fine scoreboard and a poor edit-run loop. frames: ?usize = null, + /// One cell in display pixels: 16x32 is the same window on a 2x display. + cell: pardes.CellPixels = default_cell_pixels, + /// Frames of the one-notch control row. + slow_frames: usize = default_slow_frames, }; /// The bit-exact description of one scrolling run. `visual` is the invariant; @@ -120,6 +125,9 @@ const Result = struct { total_ns: u64, rasterizations: u64, rasters_retained: u64, + /// Most bytes the pane's rasters held after any frame: every slot's + /// pixels, its clean copy, and the spare buffers kept for reuse. + peak_raster_bytes: u64, identity: Identity, }; @@ -174,13 +182,13 @@ pub fn main(init: std.process.Init) !void { // One untimed probe boot supplies the strip height every plan is sized // from, so the frame counts below are a property of the document and the // viewport rather than of the machine. - const travel = probeTravel(config.path); + const travel = probeTravel(config); const cap = config.frames orelse max_frames; const results = [_]Result{ - try measure(config, planFor(.fling_down, travel, cap)), - try measure(config, planFor(.fling_reverse, travel, cap)), - try measure(config, planFor(.slow_scroll, travel, cap)), + try measure(config, planFor(.fling_down, travel, cap, config.slow_frames)), + try measure(config, planFor(.fling_reverse, travel, cap, config.slow_frames)), + try measure(config, planFor(.slow_scroll, travel, cap, config.slow_frames)), }; if (config.json) @@ -207,9 +215,19 @@ fn parseArgs(args: []const []const u8) Config { } else if (std.mem.eql(u8, args[i], "--frames") and i + 1 < args.len) { i += 1; config.frames = parseCount("--frames", args[i]); + } else if (std.mem.eql(u8, args[i], "--slow-frames") and i + 1 < args.len) { + i += 1; + config.slow_frames = parseCount("--slow-frames", args[i]); + } else if (std.mem.eql(u8, args[i], "--cell") and i + 1 < args.len) { + i += 1; + var it = std.mem.splitScalar(u8, args[i], 'x'); + const w = parseCount("--cell", it.next() orelse ""); + const h = parseCount("--cell", it.next() orelse ""); + if (w > std.math.maxInt(u16) or h > std.math.maxInt(u16)) fatal("--cell {s} is too large", .{args[i]}); + config.cell = .{ .w = @intCast(w), .h = @intCast(h) }; } else fatal( "usage: pardes-pdf-scroll-bench [--json] [--warmup N] [--reps N]" ++ - " [--path FILE] [--frames N]", + " [--path FILE] [--frames N] [--slow-frames N] [--cell WxH]", .{}, ); } @@ -226,28 +244,35 @@ const Travel = struct { /// same benchmark and the report must not pretend otherwise. pages: usize, strip_px: u64, + /// The first frame of a fresh pane: opening the document and rasterizing + /// what it shows first. One sample, from the probe boot. + first_frame_ns: u64, }; -fn probeTravel(path: []const u8) Travel { - const core = bootCore(path) catch |err| +fn probeTravel(config: Config) Travel { + const path = config.path; + const core = bootCore(config) catch |err| fatal("cannot boot a PDF pane on {s}: {t}", .{ path, err }); defer core.deinit(); var frame_arena = std.heap.ArenaAllocator.init(gpa); defer frame_arena.deinit(); + const started = nowNs(); _ = core.render(frame_arena.allocator()) catch |err| fatal("first render failed: {t}", .{err}); drainEffects(core); + const first_frame_ns = nowNs() -| started; const pane = core.panes[0] orelse fatal("PDF pane disappeared", .{}); if (pane.pdf == null) fatal("{s} did not open as a PDF", .{path}); const pv = &pane.pdf.?; - if (pv.page_count < 4) - fatal("{s} has {d} pages; a fling benchmark needs at least 4", .{ path, pv.page_count }); if (!pv.layout_valid or pv.document_height == 0) fatal("the pixel strip never laid out; is native_images/cell_pixels wired?", .{}); // Exactly the clamp scrollPdfDocument enforces, so the last fling frame // lands on the document end instead of adding a no-op frame to the row. - const viewport_h: u64 = @as(u64, bench_rows -| pardes.BOX_H) * bench_cell_pixels.h; - const notch: f64 = @floatFromInt(bench_cell_pixels.h); + const viewport_h: u64 = @as(u64, bench_rows -| pardes.BOX_H) * config.cell.h; + // Four screenfuls of strip, in pages or in one tall page. + if (pv.document_height < 4 * viewport_h) + fatal("{s} is {d}px of strip; a fling benchmark needs at least four screenfuls", .{ path, pv.document_height }); + const notch: f64 = @floatFromInt(config.cell.h); if (!std.math.isFinite(notch) or notch <= 0) fatal("a notch of {d} display pixels cannot size a fling", .{notch}); const strip = @as(f64, @floatFromInt(pv.document_height -| viewport_h)); @@ -258,6 +283,7 @@ fn probeTravel(path: []const u8) Travel { .per_frame = notch * @as(f64, @floatFromInt(fling_ticks_per_frame)), .pages = pv.page_count, .strip_px = pv.document_height, + .first_frame_ns = first_frame_ns, }; } @@ -267,7 +293,7 @@ fn probeTravel(path: []const u8) Travel { /// The cap is applied before any arithmetic on the count, so a document the /// float division cannot describe still produces a runnable plan instead of an /// overflow. -fn planFor(scenario: Scenario, travel: Travel, cap: usize) Plan { +fn planFor(scenario: Scenario, travel: Travel, cap: usize, slow: usize) Plan { const wanted = travel.max_scroll / travel.per_frame; const whole: usize = if (std.math.isFinite(wanted) and wanted >= 1) @intFromFloat(@min(@as(f64, @floatFromInt(cap)), @ceil(wanted))) @@ -282,15 +308,15 @@ fn planFor(scenario: Scenario, travel: Travel, cap: usize) Plan { .frames = @max(2, down), .down_frames = @max(1, down / 2), }, - .slow_scroll => .{ .scenario = scenario, .frames = slow_frames, .down_frames = slow_frames }, + .slow_scroll => .{ .scenario = scenario, .frames = slow, .down_frames = slow }, }; } /// A pane on a real multi-page document with the native pixel-strip path live /// and a genuine pixel viewport delivered the way a shell delivers one. -fn bootCore(path: []const u8) !*pardes.Pardes { +fn bootCore(config: Config) !*pardes.Pardes { const core = try pardes.Pardes.init(gpa, .{ - .file = path, + .file = config.path, .cols = bench_cols, .rows = bench_rows, }); @@ -299,7 +325,7 @@ fn bootCore(path: []const u8) !*pardes.Pardes { core.update(.{ .resize = .{ .cols = bench_cols, .rows = bench_rows, - .cell_pixels = bench_cell_pixels, + .cell_pixels = config.cell, } }); drainEffects(core); return core; @@ -318,9 +344,10 @@ fn measure(config: Config, plan: Plan) !Result { var expected: ?Identity = null; var rasterizations: u64 = 0; var rasters_retained: u64 = 0; + var peak_raster_bytes: u64 = 0; for (0..config.warmup + config.reps) |round| { - const core = try bootCore(config.path); + const core = try bootCore(config); defer core.deinit(); var frame_arena = std.heap.ArenaAllocator.init(gpa); defer frame_arena.deinit(); @@ -355,6 +382,7 @@ fn measure(config: Config, plan: Plan) !Result { const elapsed = nowNs() -| started; total += elapsed; recorder.observe(frame, surface); + peak_raster_bytes = @max(peak_raster_bytes, rasterBytes(pv)); // The same frame boundary src/tty/tty.zig marks, so a Tracy capture // of this benchmark reads like a capture of the real shell. pardes.frameMark(); @@ -388,10 +416,19 @@ fn measure(config: Config, plan: Plan) !Result { .total_ns = totals[totals.len / 2], .rasterizations = rasterizations, .rasters_retained = rasters_retained, + .peak_raster_bytes = peak_raster_bytes, .identity = expected.?, }; } +/// What the pane's rasters hold right now (untimed: outside the frame). +fn rasterBytes(pv: anytype) u64 { + var total: u64 = 0; + for (pv.rasters[0..pv.rasters_len]) |raster| total += raster.rgba.len + raster.clean.len; + for (pv.spare[0..pv.spare_len]) |spare| total += spare.len; + return total; +} + /// Accumulates the two checksums across one run. Raster generations are /// rebased on the counter's value at run start so the trace describes the /// ORDER textures were produced in rather than absolute counter values, which @@ -545,23 +582,24 @@ fn reportText(config: Config, travel: Travel, results: []const Result) void { std.debug.print( "{s}: {d} pages, {d}px strip at {d}x{d} cells of {d}x{d}px\n", .{ - config.path, travel.pages, travel.strip_px, bench_cols, - bench_rows, bench_cell_pixels.w, bench_cell_pixels.h, + config.path, travel.pages, travel.strip_px, bench_cols, + bench_rows, config.cell.w, config.cell.h, }, ); + std.debug.print("first frame of a fresh pane: {d}us\n", .{travel.first_frame_ns / 1000}); std.debug.print( "warmup: {d}, sampled reps: {d}; every number is ONE FRAME (notch batch + render + drain)\n\n", .{ config.warmup, config.reps }, ); - std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:<16} {s}\n", .{ - "scenario", "frames", "notches", "min", - "median", "p90", "p99", "max", - "total_us", "rasters", "kept", "visual", - "transport", + std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:>9} {s:<16} {s}\n", .{ + "scenario", "frames", "notches", "min", + "median", "p90", "p99", "max", + "total_us", "rasters", "kept", "peak_kib", + "visual", "transport", }); std.debug.print("{s}\n", .{"-" ** 160}); for (results) |result| std.debug.print( - "{s:<16} {d:>6} {d:>7} {d:>10} {d:>10} {d:>10} {d:>10} {d:>10} {d:>11} {d:>7} {d:>5} {x:0>16} {x:0>16}\n", + "{s:<16} {d:>6} {d:>7} {d:>10} {d:>10} {d:>10} {d:>10} {d:>10} {d:>11} {d:>7} {d:>5} {d:>9} {x:0>16} {x:0>16}\n", .{ result.name, result.frames, @@ -574,12 +612,14 @@ fn reportText(config: Config, travel: Travel, results: []const Result) void { result.total_ns / 1000, result.rasterizations, result.rasters_retained, + result.peak_raster_bytes / 1024, result.identity.visual, result.identity.transport, }, ); std.debug.print( - "\n`rasters` counts MuPDF page rasterizations over one run; `kept` is the retained raster count at the end.\n" ++ + "\n`rasters` counts MuPDF page rasterizations over one run; `kept` is the retained raster count at the end;\n" ++ + "`peak_kib` is the most the pane's rasters, clean copies and spares held after any frame.\n" ++ "`visual` pins presented pixels and placement and must never change; `transport` traces texture uploads.\n", .{}, ); @@ -592,16 +632,17 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul var out: std.Io.Writer = .fixed(&storage); out.print( "{{\"benchmark\":\"pardes-pdf-scroll\",\"build\":\"{s}\",\"path\":\"{s}\"," ++ - "\"pages\":{d},\"strip_px\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[", + "\"pages\":{d},\"strip_px\":{d},\"first_frame_ns\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[", .{ - bench_config.core_optimize, config.path, travel.pages, - travel.strip_px, config.warmup, config.reps, + bench_config.core_optimize, config.path, travel.pages, + travel.strip_px, travel.first_frame_ns, config.warmup, + config.reps, }, ) catch return; for (results, 0..) |result, i| out.print( "{s}{{\"scenario\":\"{s}\",\"frames\":{d},\"notches_per_frame\":{d}," ++ "\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d},\"p99_ns\":{d},\"max_ns\":{d},\"total_ns\":{d}," ++ - "\"rasterizations\":{d},\"rasters_retained\":{d},\"images\":{d}," ++ + "\"rasterizations\":{d},\"rasters_retained\":{d},\"peak_raster_bytes\":{d},\"images\":{d}," ++ "\"visual\":\"{x:0>16}\",\"transport\":\"{x:0>16}\"}}", .{ if (i == 0) "" else ",", @@ -616,6 +657,7 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul result.total_ns, result.rasterizations, result.rasters_retained, + result.peak_raster_bytes, result.identity.images, result.identity.visual, result.identity.transport, diff --git a/test/selfmount.py b/test/selfmount.py index 86c32fa8..dd59083c 100644 --- a/test/selfmount.py +++ b/test/selfmount.py @@ -176,6 +176,16 @@ def run(binary): held = run(f"for i in $(seq 40); do cat {mount}/pane/{first}/event > /dev/null & done; " f"sleep 1; timeout 10 cat {mount}/layout > /dev/null; st=$?; kill $(jobs -p); exit $st\n".encode()) check(held == '0', f'cat layout answers beside 40 held event reads ({held!r})') + # fs.md's follow recipe, as written, with plain cat, its output + # a pipe (where coreutils cat splices): it hears a record made + # after it started. + recipe = Path(f'/tmp/{name}.follow') + ran = run(f"exec 3<>{mount}/log; echo follow >&3; " + f"(sleep 1; echo 'Msg recipe-heard' > {mount}/exec) & " + f"timeout 5 cat <&3 | cat > {recipe}; exit 0\n".encode()) + followed = recipe.read_text() if recipe.exists() else '' + recipe.unlink(missing_ok=True) + check(ran == '0' and 'recipe-heard' in followed, f'the log follow recipe with cat hears a new record ({followed[-120:]!r})') client.write('/exec', b'Msg still here\n') text = ''.join(cell[0] for cell in client.screen()['cells']) check('still here' in text, 'the editor answers after all of that') diff --git a/test/snapshots/nested-optout.golden b/test/snapshots/nested-optout.golden index 1fa7cbe5..3539a16c 100644 --- a/test/snapshots/nested-optout.golden +++ b/test/snapshots/nested-optout.golden @@ -1,24 +1,54 @@ == snap optout-file grid=100x31 cursor=9,6 -|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit -|New Tty Find Grep Joincol -|$ /tmp/pardes-snap/nested-optout/cwd Save Tty Del Togglettymode Filter Collapse -| Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit -| New Tty Find Grep Joincol -| /tmp/pardes-snap/nested-optout/cwd/nested.txt Save Tty Del Collapse +|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit +| New Tty Find Grep Joincol Delcol +|$ /tmp/pardes-snap/nested-optout/cwd Tty+bash Save Mode Filter Collapse Del +| Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit +| New Tty Find Grep Joincol Delcol +| /tmp/pardes-snap/nested-optout/cwd/nested.txt Save Tty Collapse Del | 1 optout-marker | 2 second-optout-line | 3 +| +| +| +| +| +| +| +| +| +| +| +| +| /tmp/pardes-snap/nested-optout/cwd/+New Save Tty Collapse Del +| 1 == snap inner-shell grid=100x31 cursor=6,6 -|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit -|New Tty Find Grep Joincol -|$ /tmp/pardes-snap/nested-optout/cwd Save Tty Del Togglettymode Filter Collapse -| Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit -| New Tty Find Grep Joincol -| $ /tmp/pardes-snap/nested-optout/cwd/innerdir Save Tty Del Togglettymode Filter Collapse +|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit +| New Tty Find Grep Joincol Delcol +|$ /tmp/pardes-snap/nested-optout/cwd Tty+bash Save Mode Filter Collapse Del +| Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit +| New Tty Find Grep Joincol Delcol +| $ /tmp/pardes-snap/nested-optout/cwd/innerdir Tty+bash Save Mode Filter Collapse Del | $ +| +| +| +| +| +| +| +| +| +| /tmp/pardes-snap/nested-optout/cwd/innerdir/+New Save Tty Collapse Del +| 1 +| +| +| +| /tmp/pardes-snap/nested-optout/cwd/+New Save Tty Collapse Del +| 1 == snap inner-launch grid=100x31 cursor=11,9 -|6: Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit -|7: New Tty Find Grep Joincol -|8: /tmp/pardes-snap/nested-optout/cwd/innerdir/deeper.txt Save Tty Del Collapse +|6: Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit +|7: New Tty Find Grep Joincol Delcol +|8: /tmp/pardes-snap/nested-optout/cwd/innerdir/deeper.txt Save Tty Collapse Del |9: 1 deepest-marker |10: 2 |
