summaryrefslogtreecommitdiff
path: root/test/pdf_pointer_bench.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-22 17:43:57 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit9695cb098c784792af79c22d46a17f7bc7247d33 (patch)
treefa446f3cb588b3184b7914b3f50c36cd3e5ece7b /test/pdf_pointer_bench.zig
parentf962de52df4b3d33438150920ae95b8708b0dde0 (diff)
downloadpardes-9695cb098c784792af79c22d46a17f7bc7247d33.tar.gz
pardes-9695cb098c784792af79c22d46a17f7bc7247d33.zip
Repaint PDF highlights by row, send rasters by shared memory, and animate messages
PDF highlights (hover preview, search, selection) are baked into page rasters, and any change re-rendered the whole page with MuPDF; the TTY then re-sent it as base64 (4.7 MB a page), the GUI as a new texture. Worse, a pointer motion over a PDF invalidated the page even when no preview was shown, so every motion paid that. Now: - A raster whose baked highlight set equals the wanted one is left alone. - A highlighted page keeps its clean rows (before highlights and tint); a change repaints only the rows of quads that differ, running MuPDF's highlight pass (pardes_pdf_paint_highlights) over those clean rows and tinting them: the operations a full render performs, so the pixels are identical. MuPDF band renders are NOT bit-identical to a whole page (edge rows, resampled images), so they are never used to patch; the comment claiming otherwise is corrected. - ImagePlace.patch hands shells the changed rows; the GUI uploads just those rows into the texture it holds. - The TTY probes kitty shared memory (t=s) with an id vaxis never reaches and sends rasters as a /dev/shm name when the terminal reads it; direct base64 otherwise (ssh). - Shells that take row patches (GUI, TTY with shm) repaint a selection while it is dragged instead of only on release. Latency elsewhere: - TTY: an animating frame no longer sleeps 16 ms blind; a tick thread posts into the input queue, so input inside the frame is handled at once. - TTY and GUI: queued pointer motions coalesce to the last. - GUI: a skipped swapchain image re-arms the frame (3 retries); animations still tick while nothing presents. - Editing: the line index is carried across an edit instead of rebuilt from a scan of the whole file per keystroke. Messages fall into their row (ease-in; the GUI slides the band out from under the tagline, a terminal fades it), stay until the next input as before, linger MessageLinger ms (default 800), and dissolve (ease-out). MessageAnimation toggles it; both are settings, in Config and startup files. The snapshot harness pins the old behaviour. The detached server now ticks animations. A restored terminal comes back live: the old screen and scrollback (dumped as clean VT by ghostty's formatter, replayed at the new size; older dumps fall back to their rendered text), a dim "restored history" marker, then a new shell in the directory it was in. Right-click on a line number in a file pane looks at that line (a sticky context header's number included). Measured with an external pty driver (TTY), an in-process fence trace (GUI, PARDES_TEST_LATENCY), and test/pdf_pointer_bench.zig (pixel identity against the baseline and a whole-page oracle); balanced A/A/B rounds, paired per-round statistics. Messages stack: each event gets its own row and its own fall, linger and dissolve; a line keeps its row until it leaves and a new one fills the first free row. Announcements and statuses are replaced in place, not stacked. MessageFall, MessageDissolve and DumpDir are settings Config reports. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Diffstat (limited to 'test/pdf_pointer_bench.zig')
-rw-r--r--test/pdf_pointer_bench.zig224
1 files changed, 224 insertions, 0 deletions
diff --git a/test/pdf_pointer_bench.zig b/test/pdf_pointer_bench.zig
new file mode 100644
index 00000000..f1d61229
--- /dev/null
+++ b/test/pdf_pointer_bench.zig
@@ -0,0 +1,224 @@
+//! Pointer gestures over a native PDF pane: hovering words (the Look preview
+//! highlight), and dragging a selection to its release.
+//!
+//! zig build pdf-pointer-bench -Doptimize=ReleaseFast -- [--label L] [--reps N] [--path FILE]
+//! [--cell WxH] [--scroll NOTCHES] [--verify 1] [--live 1]
+//!
+//! Every frame is timed the way a shell pump spends it: the event through
+//! `Pardes.update`, one `render()` into a reset arena, the effect drain. A
+//! hover step is its motion frame plus every tick frame the preview animates
+//! before settling, which is when the highlight lands. Stdout is CSV,
+//! `label,scenario:kind,round,ns`, one row per frame and one per step
+//! (`kind=step`), for an external statistics pass.
+//!
+//! `visual` hashes every presented raster in full, with its placement, on the
+//! frame each step settles on. It is the no-regression proof: a change to how highlights
+//! are painted may change what it costs, never one presented pixel.
+const std = @import("std");
+const pardes = @import("pardes");
+const pdf = @import("mupdf");
+
+pub const std_options: std.Options = .{ .log_level = .err };
+
+const gpa = std.heap.smp_allocator;
+const cols: u16 = 120;
+const rows: u16 = 40;
+
+const Config = struct {
+ path: []const u8 = "docs/design.pdf",
+ label: []const u8 = "run",
+ reps: usize = 5,
+ /// Wheel notches before the gestures, off the sparse title page.
+ scroll: usize = 40,
+ /// 8x16 is the Kitty policy's cell; the SDL shell's is closer to 16x35.
+ cell_w: u16 = 8,
+ cell_h: u16 = 16,
+ /// Re-render every raster that claims to hold the current highlights and
+ /// compare it row by row: exactness without a baseline build.
+ verify: bool = false,
+ /// A shell that takes row patches: selections repaint while dragged.
+ live: bool = false,
+};
+
+pub fn main(init: std.process.Init) !void {
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ var config: Config = .{};
+ var i: usize = 1;
+ while (i + 1 < args.len) : (i += 2) {
+ const value = args[i + 1];
+ if (std.mem.eql(u8, args[i], "--path")) config.path = value else if (std.mem.eql(u8, args[i], "--label")) config.label = value else if (std.mem.eql(u8, args[i], "--reps")) config.reps = try std.fmt.parseUnsigned(usize, value, 10) else if (std.mem.eql(u8, args[i], "--scroll")) config.scroll = try std.fmt.parseUnsigned(usize, value, 10) else if (std.mem.eql(u8, args[i], "--verify")) config.verify = std.mem.eql(u8, value, "1") else if (std.mem.eql(u8, args[i], "--live")) config.live = std.mem.eql(u8, value, "1") else if (std.mem.eql(u8, args[i], "--cell")) {
+ var it = std.mem.splitScalar(u8, value, 'x');
+ config.cell_w = try std.fmt.parseUnsigned(u16, it.next() orelse "", 10);
+ config.cell_h = try std.fmt.parseUnsigned(u16, it.next() orelse "", 10);
+ } else return error.UnknownOption;
+ }
+ if (i != args.len) return error.MissingValue;
+
+ var visual: ?u64 = null;
+ for (0..config.reps) |round| {
+ const got = try measure(config, round);
+ if (visual) |want| if (want != got) {
+ std.debug.print("pdf-pointer-bench: visual changed between rounds: {x} vs {x}\n", .{ want, got });
+ return error.Unstable;
+ };
+ visual = got;
+ }
+ std.debug.print("pdf-pointer-bench {s}: visual={x:0>16}\n", .{ config.label, visual.? });
+}
+
+const Run = struct {
+ core: *pardes.Pardes,
+ arena: std.heap.ArenaAllocator,
+ config: Config,
+ round: usize,
+ visual: u64 = 0x7061_7264_6573_5050,
+ step_ns: u64 = 0,
+ frames: usize = 0,
+ steps: usize = 0,
+ last: ?*const pardes.Surface = null,
+
+ fn frame(run: *Run, scenario: []const u8, kind: []const u8, event: pardes.Event) !void {
+ const started = nowNs();
+ run.core.update(event);
+ while (run.core.nextEffect()) |_| {}
+ _ = run.arena.reset(.retain_capacity);
+ const surface = try run.core.render(run.arena.allocator());
+ while (run.core.nextEffect()) |_| {}
+ const elapsed = nowNs() - started;
+ run.step_ns += elapsed;
+ run.frames += 1;
+ print("{s},{s}:{s},{d},{d}\n", .{ run.config.label, scenario, kind, run.round, elapsed });
+ run.last = surface;
+ if (run.config.verify) try verify(run.core, run.arena.allocator());
+ }
+
+ /// The step, then the tick frames its animation asks for (a hover's
+ /// preview arrives on one of them), as a pump with nothing else queued.
+ fn step(run: *Run, scenario: []const u8, kind: []const u8, event: pardes.Event) !void {
+ run.step_ns = 0;
+ try run.frame(scenario, kind, event);
+ var ticks: usize = 0;
+ while (run.core.animationActive() and ticks < 32) : (ticks += 1)
+ try run.frame(scenario, "tick", .tick);
+ print("{s},{s}:step,{d},{d}\n", .{ run.config.label, scenario, run.round, run.step_ns });
+ // Only the settled frame is the invariant: how many frames a preview
+ // takes to arrive is a setting (look_preview_delay_frames).
+ run.observe(run.last.?);
+ run.steps += 1;
+ }
+
+ fn observe(run: *Run, surface: *const pardes.Surface) void {
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ run.visual = mix(run.visual, [_]u64{ place.pane, place.x, place.y, place.w, place.h, place.native.page, place.iw, place.ih });
+ if (place.native.geometry) |g| run.visual = mix(run.visual, [_]u64{ g.src.x, g.src.y, g.src.w, g.src.h, g.dst.x, g.dst.y, g.dst.w, g.dst.h });
+ run.visual = std.hash.Wyhash.hash(run.visual, place.rgba);
+ }
+ }
+};
+
+var verify_failures: usize = 0;
+
+fn verify(core: *pardes.Pardes, arena: std.mem.Allocator) !void {
+ const Pdf = pardes.panes.Pdf;
+ const pane = core.panes[0] orelse return;
+ const state = &(pane.pdf orelse return);
+ const highlights = try Pdf.buildHighlights(state, arena, Pdf.highlightInput(core, 0, pane));
+ for (state.rasters[0..state.rasters_len]) |*r| {
+ if (!r.valid or r.rgba.len == 0 or r.tint_key == null) continue;
+ const want = highlights.forPage(r.page);
+ if (comptime !@hasField(@TypeOf(r.*), "baked")) return else if (!std.mem.eql(u8, std.mem.sliceAsBytes(want), std.mem.sliceAsBytes(r.baked))) continue;
+ const shape = try state.document.measureRenderAt(r.page, r.request);
+ // The oracle is the WHOLE page: a band's own edge rows can differ.
+ const whole = try arena.alloc(u8, shape.len);
+ try state.document.renderIntoAtWithPaper(r.page, r.request, shape, shape.wholePage(), want, whole, false);
+ try pdf.tintRgba(whole, r.tint_key.?.mode, r.tint_key.?.colors);
+ const fresh = whole[r.band_y * shape.stride ..][0 .. r.band_h * shape.stride];
+ var first: ?usize = null;
+ var last: usize = 0;
+ var count: usize = 0;
+ for (0..r.band_h) |y| {
+ const row = fresh[y * shape.stride ..][0..shape.stride];
+ if (std.mem.eql(u8, row, r.rgba[y * shape.stride ..][0..shape.stride])) continue;
+ if (first == null) first = y;
+ last = y;
+ count += 1;
+ }
+ if (first) |f| {
+ verify_failures += 1;
+ if (verify_failures <= 20) std.debug.print("verify: page {d} rows {d}..{d} ({d} differ), highlights {d}, band {d}+{d}\n", .{ r.page, f + r.band_y, last + r.band_y, count, want.len, r.band_y, r.band_h });
+ }
+ }
+}
+
+fn measure(config: Config, round: usize) !u64 {
+ const core = try pardes.Pardes.init(gpa, .{ .file = config.path, .cols = cols, .rows = rows });
+ defer core.deinit();
+ core.native_images = true;
+ if (comptime @hasField(pardes.Pardes, "native_image_patches")) core.native_image_patches = config.live;
+ var run: Run = .{ .core = core, .arena = .init(gpa), .config = config, .round = round };
+ defer run.arena.deinit();
+ const cell: pardes.CellPixels = .{ .w = config.cell_w, .h = config.cell_h };
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = cell } });
+ _ = try core.render(run.arena.allocator());
+ const pane = core.panes[0] orelse return error.NoPane;
+ if (pane.pdf == null) return error.NotPdf;
+ const rect = core.rects[0];
+ const mid_col = rect.x + rect.w / 2;
+ const mid_row = rect.y + rect.h / 2;
+ for (0..config.scroll) |_| {
+ core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mid_col, .row = mid_row } });
+ _ = run.arena.reset(.retain_capacity);
+ _ = try core.render(run.arena.allocator());
+ while (core.nextEffect()) |_| {}
+ }
+
+ const top = rect.y + 4;
+ const bottom = rect.y + rect.h -| 3;
+ const left = rect.x + 4;
+ const right = rect.x + rect.w -| 4;
+
+ // Hover: walk four rows of the body, a word-ish stride at a time.
+ var row: u16 = top;
+ while (row < bottom) : (row += (bottom - top) / 4) {
+ var col: u16 = left;
+ while (col < right) : (col += 3)
+ try run.step("hover", "motion", .{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } });
+ }
+ // Leave the page so the last preview is withdrawn like a real exit.
+ try run.step("hover", "leave", .pointer_leave);
+
+ // Select: press, drag across three lines, release; five times down the page.
+ row = top;
+ while (row + 3 < bottom) : (row += (bottom - top) / 5) {
+ try run.step("select", "press", .{ .mouse = .{ .button = .left, .kind = .press, .col = left, .row = row } });
+ var n: u16 = 1;
+ while (n <= 24) : (n += 1) {
+ const col = left + (right - left) * (n % 8) / 8;
+ try run.step("select", "drag", .{ .mouse = .{ .button = .left, .kind = .drag, .col = col, .row = row + n / 8 } });
+ }
+ try run.step("select", "release", .{ .mouse = .{ .button = .left, .kind = .release, .col = right, .row = row + 3 } });
+ }
+ return mix(run.visual, @as(u64, run.steps));
+}
+
+fn print(comptime format: []const u8, args: anytype) void {
+ var buf: [256]u8 = undefined;
+ const line = std.fmt.bufPrint(&buf, format, args) catch return;
+ _ = std.c.write(1, line.ptr, line.len);
+}
+
+fn mix(seed: u64, value: anytype) u64 {
+ var copy = value;
+ return std.hash.Wyhash.hash(seed, std.mem.asBytes(&copy));
+}
+
+fn nowNs() u64 {
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
+}
+
+comptime {
+ _ = pdf.Document;
+}