//! 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(©)); } 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; }