summaryrefslogtreecommitdiff
path: root/test/pdf.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /test/pdf.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'test/pdf.zig')
-rw-r--r--test/pdf.zig1867
1 files changed, 1867 insertions, 0 deletions
diff --git a/test/pdf.zig b/test/pdf.zig
new file mode 100644
index 00000000..b2d03bb8
--- /dev/null
+++ b/test/pdf.zig
@@ -0,0 +1,1867 @@
+const std = @import("std");
+const pardes = @import("pardes");
+const panes = pardes.panes;
+const look = pardes.look;
+const pdf_impl = panes.Pdf.pdf;
+const image = pardes.image;
+const config = pardes.config;
+const builtins = pardes.builtins;
+const layout = pardes.layout;
+const dump = pardes.dump;
+
+const Pardes = pardes.Pardes;
+const Builtin = builtins.registry.Builtin();
+const Key = pardes.Key;
+const Event = pardes.Event;
+const Mode = pardes.Pane.Mode;
+const Surface = pardes.Surface;
+const ImagePlace = pardes.ImagePlace;
+const PdfFitMode = panes.Pdf.FitMode;
+const PdfTintMode = panes.Pdf.TintMode;
+const pdf_enabled = panes.Pdf.enabled;
+const platform = pardes.platform;
+const themes = pardes.themes;
+const pdf_raster_policy = pardes.pdf_raster_policy;
+const PDF_PAGE_GAP_PX = pardes.PDF_PAGE_GAP_PX;
+const BOX_H = pardes.BOX_H;
+const sel_slot = @intFromEnum(config.select_button);
+const pane_tail = " " ++ config.pane_builtins_str;
+
+fn hasPdf(pane: *const pardes.Pane) bool {
+ return if (comptime pdf_enabled) pane.pdf != null else false;
+}
+
+test "PDF mounted bytes own their source after the caller frees it" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ const source = try pardes.filesystem.readFile(gpa, "docs/design.pdf");
+ var document = pdf_impl.Document.openBytes(source) catch |err| {
+ gpa.free(source);
+ return err;
+ };
+ @memset(source, 0);
+ gpa.free(source);
+ defer document.deinit();
+
+ var original = try pdf_impl.Document.open("docs/design.pdf");
+ defer original.deinit();
+ try std.testing.expectEqual(original.pages, document.pages);
+ try std.testing.expectEqual(try original.pageSize(0), try document.pageSize(0));
+ const raster = try document.render(gpa, 0);
+ defer gpa.free(raster.rgba);
+ try std.testing.expect(raster.rgba.len > 0);
+}
+
+test "explicit OS PDF opens and reloads keep their namespace path" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const original = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(original);
+ const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(replacement);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original });
+ var directory_buffer: [4096]u8 = undefined;
+ const directory_len = try tmp.dir.realPath(std.testing.io, &directory_buffer);
+ const path = try std.fmt.allocPrint(gpa, "/n/os{s}/live.pdf", .{directory_buffer[0..directory_len]});
+ defer gpa.free(path);
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expectEqualStrings(path, pane.pdf.?.path);
+ try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count);
+ var watched = false;
+ while (p.nextEffect()) |effect| {
+ if (effect == .watch and effect.watch.pane == 0 and effect.watch.on) watched = true;
+ }
+ try std.testing.expect(watched);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement });
+ try std.testing.expect(p.reloadWatchedFile(0, &.{}));
+ try std.testing.expectEqualStrings(path, pane.pdf.?.path);
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page_count);
+}
+
+test "PDF feature gates keep argv and builtin behavior coherent" {
+ const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
+ const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint");
+ const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections");
+ try std.testing.expectEqual(pdf_enabled, maybe_fit != null);
+ try std.testing.expectEqual(pdf_enabled, maybe_tint != null);
+ try std.testing.expectEqual(pdf_enabled, maybe_sections != null);
+ if (pdf_enabled) {
+ try std.testing.expectEqualStrings("tz", config.leader_path.get(maybe_fit.?).?);
+ try std.testing.expectEqualStrings("ti", config.leader_path.get(maybe_tint.?).?);
+ try std.testing.expectEqualStrings("ts", config.leader_path.get(maybe_sections.?).?);
+ } else if (platform != .web) {
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expect(pane.file != null);
+ try std.testing.expect(pane.image == null);
+ try std.testing.expect(!hasPdf(pane));
+ }
+}
+
+test "PDF dump fallback remains a byte-preserving file" {
+ const gpa = std.testing.allocator;
+ const path = "/definitely/missing/pardes-dump-fallback.PDF";
+ const source = "%PDF embedded fallback bytes\x00\xff";
+ const encoded = try dump.encodeBytes(gpa, source);
+ defer gpa.free(encoded);
+ const ids = [_]usize{0};
+ const columns = [_]dump.Column{.{ .panes = &ids }};
+ const saved_panes = [_]dump.Pane{.{
+ .kind = .image,
+ .tag = "pdf 3/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del",
+ .body = "",
+ .scroll = 2,
+ .cols = 80,
+ .rows = 24,
+ .image = .{ .path = path, .bytes_b64 = encoded },
+ }};
+ const state = dump.State{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .columns = &columns,
+ .panes = &saved_panes,
+ };
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+
+ const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, out.written());
+ defer restored.deinit();
+ const pane = restored.panes[0].?;
+ try std.testing.expect(pane.file != null);
+ try std.testing.expect(pane.image == null);
+ try std.testing.expect(!hasPdf(pane));
+ try std.testing.expectEqualStrings(path, pane.file.?.path);
+ try std.testing.expectEqualSlices(u8, source, pane.file.?.content);
+ try std.testing.expect(pane.tag_init);
+ try std.testing.expectEqualStrings(" Keep Del", pane.tag_tail[0..pane.tag_tail_len]);
+}
+
+test "PdfSections Look follows the exact owning PDF, not an equal path" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
+
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ const first = p.panes[0].?;
+ // Bare normal-mode `f` on a PDF dispatches the PdfSections builtin.
+ p.update(.{ .key = .{ .cp = 'f', .text = "f" } });
+ const first_output_id = first.search_pane orelse return error.MissingPdfSectionsOutput;
+ const first_output = p.panes[first_output_id].?;
+ try std.testing.expect(panes.Pdf.isSectionsOutput(first_output));
+ try std.testing.expect(first_output.file.?.content.len > 0);
+
+ // A clean result is re-armed in place without rebuilding it.
+ const first_revision = first_output.file.?.revision;
+ panes.Pdf.openSections(p, 0);
+ try std.testing.expectEqual(first_output_id, first.search_pane.?);
+ try std.testing.expectEqual(first_revision, first_output.file.?.revision);
+
+ const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot;
+ const duplicate = try panes.Pdf.openPane(p, duplicate_id, path, 0);
+ p.placeDoc(0, duplicate_id, duplicate);
+ layout.compute(p);
+ panes.Pdf.openSections(p, duplicate_id);
+ const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections;
+
+ const first_row = std.mem.sliceTo(first_output.file.?.content, '\n');
+ const target = first_row[0 .. std.mem.indexOfScalar(u8, first_row, ' ') orelse first_row.len];
+ p.lookAt(first_output_id, target);
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ p.lookAt(duplicate_output_id, target);
+ try std.testing.expectEqual(duplicate_id, p.active);
+
+ // Once the original document is gone, its output cannot reinterpret an
+ // old ordinal against the still-open equal-path duplicate.
+ try std.testing.expect(pardes.test_api.runBuiltin(p, "Del", 0, "", null));
+ p.active = first_output_id;
+ p.lookAt(first_output_id, target);
+ try std.testing.expectEqual(first_output_id, p.active);
+}
+
+test "PdfSections caches an empty outline output" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path});
+ const p = try Pardes.init(gpa, .{ .file = path });
+ defer p.deinit();
+ panes.Pdf.openSections(p, 0);
+ const pane = p.panes[0].?;
+ const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections;
+ try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len);
+ const serial = p.panes[output_id].?.serial;
+ panes.Pdf.openSections(p, 0);
+ try std.testing.expectEqual(output_id, pane.search_pane.?);
+ try std.testing.expectEqual(serial, p.panes[output_id].?.serial);
+}
+
+test "SDL PDF raster policy is materially denser than Kitty" {
+ try std.testing.expect(
+ pardes.sdl_pdf_raster_policy.dpi >= pardes.kitty_pdf_raster_policy.dpi * 2,
+ );
+ try std.testing.expect(
+ pardes.sdl_pdf_raster_policy.max_dimension >
+ pardes.kitty_pdf_raster_policy.max_dimension * 3,
+ );
+ try std.testing.expect(!pardes.kitty_pdf_raster_policy.match_viewport);
+ try std.testing.expect(pardes.sdl_pdf_raster_policy.match_viewport);
+}
+
+test "watched PDF reload replaces MuPDF state and preserves the reading view" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const original = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(original);
+ const replacement = try pdf_impl.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(replacement);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(
+ &path_buf,
+ ".zig-cache/tmp/{s}/live.pdf",
+ .{tmp.sub_path},
+ );
+
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ var watched = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == 0 and watch.on) {
+ watched = true;
+ },
+ else => {},
+ };
+ try std.testing.expect(watched);
+
+ const pane = p.panes[0].?;
+ const state = &pane.pdf.?;
+ try std.testing.expectEqual(@as(usize, 3), state.page_count);
+ panes.Pdf.openSections(p, 0);
+ const sections_id = pane.search_pane orelse return error.MissingPdfSectionsOutput;
+ const sections = p.panes[sections_id].?;
+ try std.testing.expect(sections.file.?.content.len > 0);
+
+ p.native_images = true;
+ state.fit = .height;
+ state.tint = .full;
+ state.pan_x = 1234;
+ state.pan_y = 4321;
+ try state.setSearchQuery(p.pdf_gpa, "needle");
+ panes.Pdf.setPage(p, pane, 2);
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ state.text_scroll = 7;
+ state.search_hit = 4;
+ state.search_reveal_pending = false;
+ const reveal_viewport_w = state.reveal_viewport_w;
+ const reveal_viewport_h = state.reveal_viewport_h;
+ const reveal_fit = state.reveal_fit;
+ const reveal_viewport_valid = state.reveal_viewport_valid;
+ const old_revision = panes.Pdf.rasterForPage(state, 2).?.revision;
+ const old_revision_counter = state.next_raster_revision;
+ const anchor_fraction: f64 = 0.375;
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[2])) +
+ anchor_fraction * @as(f64, @floatFromInt(state.page_heights[2]));
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ pane.cur_row = 9;
+ pane.cur_col = 8;
+
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = replacement });
+ try std.testing.expect(p.reloadWatchedFile(0, &.{}));
+
+ try std.testing.expectEqual(@as(usize, 1), state.page_count);
+ try std.testing.expectEqual(@as(usize, 0), state.page);
+ try std.testing.expectEqual(PdfFitMode.height, state.fit);
+ try std.testing.expectEqual(PdfTintMode.full, state.tint);
+ try std.testing.expectEqual(@as(u16, 1234), state.pan_x);
+ try std.testing.expectEqual(@as(u16, 4321), state.pan_y);
+ try std.testing.expectEqual(@as(usize, 7), state.text_scroll);
+ try std.testing.expect(state.text_scroll_clamp_pending);
+ try std.testing.expectEqualStrings("needle", state.search_query);
+ try std.testing.expectEqual(@as(usize, 4), state.search_hit);
+ try std.testing.expectEqual(reveal_viewport_w, state.reveal_viewport_w);
+ try std.testing.expectEqual(reveal_viewport_h, state.reveal_viewport_h);
+ try std.testing.expectEqual(reveal_fit, state.reveal_fit);
+ try std.testing.expectEqual(reveal_viewport_valid, state.reveal_viewport_valid);
+ try std.testing.expectEqual(old_revision_counter, state.next_raster_revision);
+ try std.testing.expectEqual(@as(usize, 0), state.rasters_len);
+ try std.testing.expect(state.layout_anchor_pending);
+ try std.testing.expectEqual(@as(usize, 2), state.layout_anchor_page);
+ try std.testing.expectApproxEqAbs(anchor_fraction, state.layout_anchor_fraction, 0.0001);
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_col);
+ _ = state.ensureText(p.pdf_gpa);
+ const text_lines = std.mem.count(u8, state.text, "\n") + 1;
+ try std.testing.expect(state.text_scroll < text_lines);
+ try std.testing.expect(!state.text_scroll_clamp_pending);
+
+ // A clean generated outline is derived data: it refreshes in place and
+ // remains the remembered output. This replacement deliberately has none.
+ try std.testing.expectEqual(@as(usize, 0), sections.file.?.content.len);
+ try std.testing.expectEqual(sections_id, state.sections_output.?.pane);
+ try std.testing.expectEqual(sections.file.?.revision, state.sections_output.?.revision);
+
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const fresh_raster = panes.Pdf.rasterForPage(state, 0).?;
+ try std.testing.expect(fresh_raster.revision != old_revision);
+ try std.testing.expect(fresh_raster.revision > old_revision_counter);
+ const expected_scroll = anchor_fraction *
+ @as(f64, @floatFromInt(state.page_heights[0]));
+ const viewport = panes.Pdf.paneViewport(p, pane).?;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ try std.testing.expectApproxEqAbs(@min(expected_scroll, max_scroll), state.document_scroll_y, 0.001);
+
+ // Reopen is transactional: malformed replacement bytes leave the last
+ // good MuPDF handle and every durable setting untouched.
+ const good_handle = state.document.handle;
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = "not a PDF" });
+ try std.testing.expect(!p.reloadWatchedFile(0, &.{}));
+ try std.testing.expectEqual(good_handle, state.document.handle);
+ try std.testing.expectEqual(@as(usize, 1), state.page_count);
+ try std.testing.expectEqualStrings("needle", state.search_query);
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PDF reload") != null);
+
+ // A user edit breaks the generated-revision token. A later valid reload
+ // updates the document but leaves those user-owned output bytes alone.
+ panes.File.setContent(p, &sections.file.?, try gpa.dupe(u8, "edited sections\n"));
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "live.pdf", .data = original });
+ try std.testing.expect(p.reloadWatchedFile(0, &.{}));
+ try std.testing.expectEqual(@as(usize, 3), state.page_count);
+ try std.testing.expectEqualStrings("edited sections\n", sections.file.?.content);
+
+ // The watch follows the PDF payload's lifetime just like a text file's;
+ // emit the stop while the slot still identifies the disappearing pane.
+ try p.deinitPane(pane);
+ p.panes[0] = null;
+ var unwatched = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == 0 and !watch.on) {
+ unwatched = true;
+ },
+ else => {},
+ };
+ try std.testing.expect(unwatched);
+}
+
+test "PDF reload does not re-center an already revealed matching search" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ const fixture = try pardes.filesystem.readFile(gpa, "docs/design.pdf");
+ defer gpa.free(fixture);
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/search.pdf", .{tmp.sub_path});
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ p.native_images = true;
+ const pane = p.panes[0].?;
+ const state = &pane.pdf.?;
+ try state.setSearchQuery(p.pdf_gpa, "Pardes");
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ try std.testing.expect(state.search_results != null);
+ try std.testing.expect(state.search_results.?.hit_count > 0);
+ try std.testing.expect(state.reveal_viewport_valid);
+
+ // Reading moved on after the original reveal. A reload retains the query
+ // and rebuilds its quads, but it must not mistake that for a new request to
+ // jump back to the hit.
+ const anchor_page = state.page;
+ const anchor_fraction: f64 = 0.82;
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[anchor_page])) +
+ anchor_fraction * @as(f64, @floatFromInt(state.page_heights[anchor_page]));
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ state.search_reveal_pending = false;
+ const reveal_w = state.reveal_viewport_w;
+ const reveal_h = state.reveal_viewport_h;
+ const reveal_fit = state.reveal_fit;
+
+ // Same semantic document on a fresh generation keeps the expected anchor
+ // easy to state while still rebuilding every MuPDF-owned search object.
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "search.pdf", .data = fixture });
+ try std.testing.expect(p.reloadWatchedFile(0, &.{}));
+ try std.testing.expectEqual(reveal_w, state.reveal_viewport_w);
+ try std.testing.expectEqual(reveal_h, state.reveal_viewport_h);
+ try std.testing.expectEqual(reveal_fit, state.reveal_fit);
+ try std.testing.expect(!state.search_reveal_pending);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+
+ const page = @min(anchor_page, state.page_count - 1);
+ const anchored = @as(f64, @floatFromInt(state.page_starts[page])) +
+ anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page]));
+ const viewport = panes.Pdf.paneViewport(p, pane).?;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ try std.testing.expectApproxEqAbs(@min(anchored, max_scroll), state.document_scroll_y, 0.001);
+}
+
+test "MuPDF pane renders, navigates, searches, and round-trips its page" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expect(hasPdf(pane));
+ try std.testing.expect(pane.pdf.?.page_count > 1);
+ try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y);
+ try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w);
+ try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h);
+ const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null);
+ try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail));
+
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const first = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 1), first.nimages);
+ const first_place = first.images[0].?;
+ try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4);
+ try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit);
+ const request = panes.Pdf.renderRequest(
+ panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport,
+ pdf_raster_policy,
+ );
+ try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi);
+ try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension);
+ try std.testing.expectEqual(
+ request,
+ panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.request,
+ );
+ try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension);
+ if (pdf_raster_policy.match_viewport) {
+ const viewport = panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport;
+ try std.testing.expectEqual(viewport.pixel_w, request.minimum_width);
+ try std.testing.expectEqual(viewport.pixel_h, request.minimum_height);
+ try std.testing.expect(first_place.iw >= viewport.pixel_w or
+ @max(first_place.iw, first_place.ih) == request.max_dimension);
+ try std.testing.expect(first_place.ih >= viewport.pixel_h or
+ @max(first_place.iw, first_place.ih) == request.max_dimension);
+ } else {
+ try std.testing.expectEqual(@as(u32, 0), request.minimum_width);
+ try std.testing.expectEqual(@as(u32, 0), request.minimum_height);
+ }
+
+ // SDL's raw dy path retains fractions in the placement itself; it does
+ // not leave native pixels fixed while only the underlying cells slide.
+ p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } });
+ try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y);
+ _ = frame.reset(.retain_capacity);
+ const fractional = try p.render(frame.allocator());
+ try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision);
+ try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y);
+ pane.pdf.?.document_scroll_y = 0;
+
+ // The default reading view moves one exact display-cell distance without
+ // replacing page pixels. Document placement, not texture identity, moves.
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page);
+ try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y);
+ try std.testing.expectEqual(
+ first_place.native.revision,
+ panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision,
+ );
+ _ = frame.reset(.retain_capacity);
+ const panned = try p.render(frame.allocator());
+ try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision);
+ try std.testing.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y);
+
+ const row_scroll = pane.pdf.?.document_scroll_y;
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '3' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 3), pane.pdf.?.document_scroll_y);
+
+ // Counts survive a shared multi-key prefix. An invalid continuation is
+ // consumed and clears both prefix and count before the following action.
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 2), pane.pdf.?.document_scroll_y);
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '4' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = '?' } });
+ try std.testing.expectEqual(.none, pane.normal.prefix);
+ try std.testing.expectEqual(@as(u32, 0), pane.normal.count);
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y);
+
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
+ const half_scroll = pane.pdf.?.document_scroll_y;
+ try std.testing.expect(half_scroll > row_scroll);
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
+ const counted_half = pane.pdf.?.document_scroll_y;
+ try std.testing.expectEqual(half_scroll * 2, counted_half);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
+ try std.testing.expectEqual(@as(f64, 0), pane.pdf.?.document_scroll_y);
+ pane.pdf.?.document_scroll_y = @as(f64, p.cell_pixels.h) * 4;
+ p.update(.{ .key = .{ .cp = '3' } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y);
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
+ try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll);
+ pane.pdf.?.document_scroll_y = 0;
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
+ const key_viewport = panes.Pdf.paneViewport(p, pane).?;
+ const max_key_scroll = @as(f64, @floatFromInt(pane.pdf.?.document_height -| key_viewport.pixel_h));
+ const counted_full = @min(@as(f64, @floatFromInt(key_viewport.pixel_h * 2)), max_key_scroll);
+ try std.testing.expectEqual(counted_full, pane.pdf.?.document_scroll_y);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
+ try std.testing.expectEqual(@max(@as(f64, 0), counted_full - @as(f64, @floatFromInt(key_viewport.pixel_h * 2))), pane.pdf.?.document_scroll_y);
+ try std.testing.expectEqual(
+ first_place.native.revision,
+ panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision,
+ );
+
+ // At a page boundary both page rasters coexist, the gap remains uncovered,
+ // and a row step crosses it without snapping either page to an edge.
+ //
+ // This lands by ASSIGNMENT, which is a jump and not a fling — and the
+ // travel counter has to say so: the keys above scrolled two screenfuls
+ // without any frame in between to spend that distance, which no shell does
+ // (every wheel batch is followed by a draw). Left unspent it would make the
+ // frame below the first frame of a fling and hand it bands.
+ const viewport = panes.Pdf.paneViewport(p, pane).?;
+ pane.pdf.?.scroll_travel = 0;
+ pane.pdf.?.document_scroll_y = @floatFromInt(
+ pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2,
+ );
+ _ = frame.reset(.retain_capacity);
+ const boundary = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 2), boundary.nimages);
+ try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page);
+ try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page);
+ const first_bottom = @as(f32, @floatFromInt(
+ boundary.images[0].?.native.geometry.?.dst.y +
+ boundary.images[0].?.native.geometry.?.dst.h,
+ )) + boundary.images[0].?.native.pixel_offset_y;
+ const second_top = @as(f32, @floatFromInt(
+ boundary.images[1].?.native.geometry.?.dst.y,
+ )) + boundary.images[1].?.native.pixel_offset_y;
+ const visible_gap = second_top - first_bottom;
+ try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX));
+ try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1));
+ const before_boundary_scroll = pane.pdf.?.document_scroll_y;
+ const page0_revision = boundary.images[0].?.native.revision;
+ const page1_revision = boundary.images[1].?.native.revision;
+
+ // A scroll can activate the already-cached neighbor before the shell's
+ // next draw. Every consumer resolves the active page's resident raster,
+ // so a queued click cannot accidentally use page zero's dimensions.
+ pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5;
+ try std.testing.expectEqual(
+ page0_revision,
+ panes.Pdf.rasterForPage(&pane.pdf.?, 0).?.revision,
+ );
+ try std.testing.expect(panes.Pdf.scrollPane(p, pane, 1));
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
+ try std.testing.expectEqual(
+ page1_revision,
+ panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision,
+ );
+ panes.Pdf.activatePage(p, pane, 0, false);
+ pane.pdf.?.document_scroll_y = before_boundary_scroll;
+
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y);
+ _ = frame.reset(.retain_capacity);
+ const second = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 2), second.nimages);
+ try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision);
+ try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision);
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y);
+
+ // Once page zero is wholly outside the viewport, both its owned RGBA and
+ // backend placement disappear; returning later renders a new raster. What
+ // does NOT go back is the memory: the departing page's bytes are parked in
+ // the relay and the arriving page of the same size takes them, so a fling
+ // never asks the allocator (or the kernel's fault handler) for megabytes it
+ // just gave up.
+ panes.Pdf.setPage(p, pane, 1);
+ _ = frame.reset(.retain_capacity);
+ const away = try p.render(frame.allocator());
+ try std.testing.expect(away.nimages > 0);
+ for (away.images[0..away.nimages]) |maybe| if (maybe) |place|
+ try std.testing.expect(place.native.page != 0);
+ try std.testing.expect(panes.Pdf.rasterForPage(&pane.pdf.?, 0) == null);
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len);
+ const retired = pane.pdf.?.spare[0];
+ panes.Pdf.setPage(p, pane, 0);
+ _ = frame.reset(.retain_capacity);
+ const returned = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page);
+ try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr);
+ try std.testing.expect(returned.images[0].?.native.revision != page0_revision);
+
+ // PdfFit exists as a real builtin in this build. It resets placement but
+ // preserves the current page pixels; fit-height j/k remains continuous in
+ // the same document-pixel coordinate space.
+ const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse
+ return error.MissingPdfFitBuiltin;
+ const revision_before_toggle = panes.Pdf.rasterForPage(
+ &pane.pdf.?,
+ pane.pdf.?.page,
+ ).?.revision;
+ try std.testing.expect(pardes.test_api.runBuiltin(
+ p,
+ @tagName(fit_builtin),
+ 0,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y);
+ try std.testing.expectEqual(
+ revision_before_toggle,
+ panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision,
+ );
+ const height_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null);
+ for (p.panes) |slot| {
+ const other = slot orelse continue;
+ if (hasPdf(other)) continue;
+ panes.Pdf.toggleFit(other); // pane-scoped and deliberately inert here
+ try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit);
+ break;
+ }
+
+ // A fit-height landscape page exposes horizontal overflow to a horizontal
+ // wheel without rerasterizing. Use synthetic dimensions only for the
+ // geometry check; no frame is drawn while they differ from the buffer.
+ const active_raster = panes.Pdf.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?;
+ const saved_iw = active_raster.iw;
+ const saved_ih = active_raster.ih;
+ active_raster.iw = 2000;
+ active_raster.ih = 500;
+ panes.Pdf.horizontalWheel(p, pane, 1);
+ try std.testing.expect(pane.pdf.?.pan_x > 0);
+ try std.testing.expectEqual(revision_before_toggle, active_raster.revision);
+ pane.pdf.?.pan_x = 0;
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expect(pane.pdf.?.pan_x > 0);
+ p.update(.{ .key = .{ .cp = '$' } });
+ try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expect(pane.pdf.?.pan_x < std.math.maxInt(u16));
+ p.update(.{ .key = .{ .cp = '0' } });
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'h' } });
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ active_raster.iw = saved_iw;
+ active_raster.ih = saved_ih;
+ pane.pdf.?.pan_x = 0;
+
+ const before_height_scroll = pane.pdf.?.document_scroll_y;
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll);
+
+ const revision_before_search = active_raster.revision;
+ try p.runSearch(0, "Pardes", .text, .top);
+ try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
+ try std.testing.expectEqual(revision_before_search, active_raster.revision);
+ const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
+ const results = p.panes[results_id].?.file.?.content;
+ try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null);
+ // n SELECTS the first result row and opens nothing: the walk's only list
+ // here is the unlooked +Search buffer, so focus lands THERE. Enter is what
+ // jumps, and the document query survives both.
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(results_id, p.active);
+ const results_pane = p.panes[results_id].?;
+ try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit);
+ try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col);
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
+
+ // Search state is owned and untruncated, and changing pages invalidates
+ // only page-local state while retaining the document query.
+ const long_query = "a query deliberately longer than any tag display budget: " ++
+ "012345678901234567890123456789012345678901234567890123456789" ++
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ try pane.pdf.?.setSearchQuery(p.pdf_gpa, long_query);
+ try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
+ const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0;
+ panes.Pdf.setPage(p, pane, next_page);
+ try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
+
+ // PDF dumps intentionally retain the existing image-compatible schema:
+ // page/path and the exact editable tail are restored, while pane-local
+ // tint starts from the fresh-PDF default rather than being serialized.
+ panes.Pdf.toggleTint(pane);
+ try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint);
+ pane.pdf.?.fit = .height;
+ const custom_tail = " Keep Del";
+ @memcpy(pane.tag_tail[0..custom_tail.len], custom_tail);
+ pane.tag_tail_len = custom_tail.len;
+ pane.tag_init = true;
+ try p.dumpState();
+ const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?);
+ defer restored.deinit();
+ try std.testing.expect(hasPdf(restored.panes[0].?));
+ try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page);
+ try std.testing.expectEqual(PdfFitMode.width, restored.panes[0].?.pdf.?.fit);
+ try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint);
+ const restored_pane = restored.panes[0].?;
+ try std.testing.expect(restored_pane.tag_init);
+ try std.testing.expectEqualStrings(
+ custom_tail,
+ restored_pane.tag_tail[0..restored_pane.tag_tail_len],
+ );
+}
+
+test "a fling's banded pages show the reader exactly what whole pages would" {
+ if (!pdf_enabled or platform == .web) return;
+
+ // The contract fast scrolling is allowed to change: HOW pixels are carried
+ // (a strip of a page instead of the page) but never WHICH pixels arrive. So
+ // the same frame is drawn twice — once at fling speed, once at reading
+ // speed — and every pixel inside every source rectangle must match, along
+ // with where on screen it goes.
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .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;
+ // Land mid-page-boundary so the frame carries TWO pages, each showing a
+ // fraction of itself — the shape a fling actually produces.
+ const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3));
+ const Shot = struct {
+ page: u32,
+ dst: image.PixelRect,
+ pixels: []u8,
+ };
+ var shots: [8]Shot = undefined;
+ var shots_len: usize = 0;
+ defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels);
+
+ // A fling: one frame's worth of wheel travel carrying the viewport more
+ // than a screenful, delivered through the real scroll path so the distance
+ // is counted the way a wheel batch counts it.
+ pv.document_scroll_y = 0;
+ pv.scroll_travel = 0;
+ try std.testing.expect(panes.Pdf.scrollPane(p, pane, landing));
+ _ = frame.reset(.retain_capacity);
+ const flung = try p.render(frame.allocator());
+ try std.testing.expect(flung.nimages >= 2);
+ var banded = false;
+ for (flung.images[0..flung.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
+ const raster = panes.Pdf.rasterForPage(pv, place.native.page) orelse
+ return error.MissingPdfRaster;
+ if (raster.band_h < raster.ih) banded = true;
+ try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len);
+ shots[shots_len] = .{
+ .page = place.native.page,
+ .dst = geometry.dst,
+ .pixels = try copySourceRect(gpa, place, geometry.src),
+ };
+ shots_len += 1;
+ }
+ // ...and it really did band, or the comparison below is two identical
+ // whole-page renders agreeing with each other.
+ try std.testing.expect(banded);
+ // ...at reading speed: no travel at all since the frame above, so every
+ // page is rasterized whole again, and that is the picture the banded frame
+ // has to have matched.
+ _ = frame.reset(.retain_capacity);
+ const rested = try p.render(frame.allocator());
+ try std.testing.expectEqual(shots_len, rested.nimages);
+ for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| {
+ const place = maybe orelse return error.MissingPdfPlacement;
+ const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
+ const raster = panes.Pdf.rasterForPage(pv, place.native.page) orelse
+ return error.MissingPdfRaster;
+ try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest
+ try std.testing.expectEqual(shot.page, place.native.page);
+ try std.testing.expectEqual(shot.dst.x, geometry.dst.x);
+ try std.testing.expectEqual(shot.dst.y, geometry.dst.y);
+ try std.testing.expectEqual(shot.dst.w, geometry.dst.w);
+ try std.testing.expectEqual(shot.dst.h, geometry.dst.h);
+ const whole = try copySourceRect(gpa, place, geometry.src);
+ defer gpa.free(whole);
+ try std.testing.expectEqualSlices(u8, shot.pixels, whole);
+ }
+}
+
+/// 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.
+fn copySourceRect(
+ gpa: std.mem.Allocator,
+ place: ImagePlace,
+ src: image.PixelRect,
+) ![]u8 {
+ const stride = place.iw * 4;
+ const row_len = @as(usize, src.w) * 4;
+ const out = try gpa.alloc(u8, row_len * src.h);
+ errdefer gpa.free(out);
+ var row: usize = 0;
+ while (row < src.h) : (row += 1) {
+ const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4;
+ @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]);
+ }
+ return out;
+}
+
+test "PDF normal adapter consumes unsupported actions and navigates page fallback" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ try std.testing.expect(pv.page_count > 3);
+ p.native_images = false;
+
+ // Every vertical vocabulary falls back to counted page changes when the
+ // shell cannot place native pixels.
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 2), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(usize, 0), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
+ try std.testing.expectEqual(@as(usize, 2), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
+ try std.testing.expectEqual(@as(usize, 0), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
+ try std.testing.expectEqual(@as(usize, 2), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
+ try std.testing.expectEqual(@as(usize, 0), pv.page);
+
+ // Prefix actions and the counted text goto-line action map to pages.
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'e' } });
+ try std.testing.expectEqual(pv.page_count - 1, pv.page);
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ try std.testing.expectEqual(@as(usize, 0), pv.page);
+ p.update(.{ .key = .{ .cp = '3' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ p.update(.{ .key = .{ .cp = 'g' } });
+ try std.testing.expectEqual(@as(usize, 2), pv.page);
+ p.update(.{ .key = .{ .cp = '2' } });
+ p.update(.{ .key = .{ .cp = 'G' } });
+ try std.testing.expectEqual(@as(usize, 1), pv.page);
+
+ // Editing/selection actions are consumed no-ops: parser state clears,
+ // placeholder cells never acquire a range, and PDF state stays intact.
+ const page_before_noop = pv.page;
+ const revision_before_noop = pv.next_raster_revision;
+ p.update(.{ .key = .{ .cp = '4' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ p.update(.{ .key = .{ .cp = 'v' } });
+ p.update(.{ .key = .{ .cp = '|' } });
+ try std.testing.expectEqual(page_before_noop, pv.page);
+ try std.testing.expectEqual(revision_before_noop, pv.next_raster_revision);
+ try std.testing.expectEqual(@as(u32, 0), pane.normal.count);
+ try std.testing.expectEqual(.none, pane.normal.prefix);
+ try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0);
+ try std.testing.expect(!pane.tag_edit);
+
+ // Cross-pane BODY-NORMAL actions keep their established shared paths.
+ p.update(.{ .key = .{ .cp = ':' } });
+ try std.testing.expect(pane.tag_edit);
+ try std.testing.expectEqual(Mode.normal, pane.mode);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(!pane.tag_edit);
+ p.update(.{ .key = .{ .cp = ' ' } });
+ try std.testing.expect(p.leader_on);
+ p.update(.{ .key = .{ .cp = '!' } });
+ try std.testing.expect(!p.leader_on);
+ p.update(.{ .key = .{ .cp = '/' } });
+ try std.testing.expect(pane.tag_edit);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(!pane.tag_edit);
+}
+
+test "Escape cancels PDF chrome in place and Shift-Escape leaves the pane" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 28,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+
+ // A second pane, then focus it and come back: `Last` has somewhere to go
+ // and the PDF is where the keys land.
+ panes.Pdf.openSections(p, 0);
+ p.update(.{ .key = .{ .cp = 'w', .ctrl = true } });
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 1), p.active);
+ p.update(.{ .key = .{ .cp = 'w', .ctrl = true } });
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+
+ // Everything a reader can see over the page: a search overlay and a live
+ // word selection.
+ try pv.setSearchQuery(p.pdf_gpa, "Pardes");
+ var found = try pv.document.search(gpa, pv.page, "Pardes");
+ defer found.deinit(gpa);
+ try std.testing.expect(found.quads.len > 0);
+ const quad = found.quads[0].quad;
+ try std.testing.expectEqual(
+ panes.Pdf.SelectionUpdate.changed,
+ pv.setSelection(p.pdf_gpa, quad.ul, quad.lr, false),
+ );
+ p.update(.{ .key = .{ .cp = '3' } });
+ const page_before = pv.page;
+ const scroll_before = pv.document_scroll_y;
+
+ // Escape drops the overlay and the selection, keeps the reading position,
+ // and does NOT hand the keyboard to another pane.
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expect(pv.selection == null and pv.selection_text.len == 0);
+ try std.testing.expectEqual(@as(usize, 0), pv.search_query.len);
+ try std.testing.expect(pv.search_results == null);
+ try std.testing.expect(!pv.highlights_dirty and !pv.search_reveal_pending);
+ try std.testing.expectEqual(page_before, pv.page);
+ try std.testing.expectEqual(scroll_before, pv.document_scroll_y);
+ try std.testing.expectEqual(@as(u32, 0), pane.normal.count);
+ try std.testing.expectEqual(.none, pane.normal.prefix);
+ // A second Escape on a bare document is inert rather than an exit.
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+
+ // Shift-Escape is the way out, and it leaves the document as it found it.
+ p.update(.{ .key = .{ .cp = Key.escape, .shift = true } });
+ try std.testing.expectEqual(@as(usize, 1), p.active);
+ try std.testing.expectEqual(page_before, pv.page);
+ try std.testing.expectEqual(scroll_before, pv.document_scroll_y);
+}
+
+test "PDF continuous strip renders every intersecting short page" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ try std.testing.expect(pv.page_count > 3);
+ p.native_images = true;
+
+ // A legal wide MediaBox can make more than three pages intersect one
+ // viewport. Seed tiny matching rasters so this tests transport/cache
+ // cardinality without spending the unit test rendering fake page sizes.
+ for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 };
+ pv.layout_valid = false;
+ pv.scroll_to_page_pending = true;
+ 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);
+ 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);
+ @memset(rgba, @intCast(page));
+ pv.rasters[pv.rasters_len] = .{
+ .valid = true,
+ .page = page,
+ .rgba = rgba,
+ .iw = viewport.pixel_w,
+ .ih = 1,
+ .request = request,
+ .request_valid = true,
+ .tried = true,
+ .tint_key = .{ .mode = pv.tint, .colors = panes.Pdf.tintColors(p) },
+ .revision = @intCast(page + 1),
+ };
+ pv.rasters_len += 1;
+ }
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(pv.page_count, surface.nimages);
+ try std.testing.expectEqual(pv.page_count, pv.rasters_len);
+ for (surface.images[0..surface.nimages], 0..) |maybe, page| {
+ const place = maybe orelse return error.MissingShortPdfPage;
+ try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page);
+ try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h);
+ }
+}
+
+test "PdfTint cycles pane-local state and exposes it in the live PDF tag" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse
+ return error.MissingPdfTintBuiltin;
+
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ initial_tag,
+ "width PdfFit filtered PdfTint",
+ ) != null);
+
+ try std.testing.expect(pardes.test_api.runBuiltin(
+ p,
+ @tagName(tint_builtin),
+ 0,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+ const full_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, full_tag, "full PdfTint") != null);
+
+ // Running the same pane-scoped word in a terminal cannot mutate the PDF
+ // next to it. A FILE boot is one pane now, so that terminal is asked for
+ // here rather than inherited from init.
+ _ = try p.newShell(1, "");
+ _ = layout.splitColumn(p, 0, 1, false);
+ pardes.test_api.sync(p); // rects for the new column, exactly as the two-pane boot did
+ try std.testing.expect(!hasPdf(p.panes[1].?));
+ try std.testing.expect(pardes.test_api.runBuiltin(
+ p,
+ @tagName(tint_builtin),
+ 1,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+
+ try std.testing.expect(pardes.test_api.runBuiltin(
+ p,
+ @tagName(tint_builtin),
+ 0,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ const disabled_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null);
+ try std.testing.expect(pardes.test_api.runBuiltin(
+ p,
+ @tagName(tint_builtin),
+ 0,
+ "",
+ null,
+ ));
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ try std.testing.expectEqual(PdfFitMode.width, pv.fit);
+
+ // `dark` intentionally leaves page bg/fg null. PDF tint resolves those
+ // deterministically to its chrome colors rather than host defaults.
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ p.setThemeIndex(dark_index);
+ const colors = panes.Pdf.tintColors(p);
+ try std.testing.expectEqual(p.theme().tag_bg, colors.background);
+ try std.testing.expectEqual(p.theme().tag_fg, colors.foreground);
+}
+
+test "PDF tint and tinted theme changes rebuild every visible raster only" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ _ = try p.render(frame.allocator());
+ const viewport = panes.Pdf.paneViewport(p, pane).?;
+ pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2);
+ _ = frame.reset(.retain_capacity);
+ const default_surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ try std.testing.expectEqual(@as(usize, 2), default_surface.nimages);
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
+
+ const Snapshot = struct {
+ page: u32,
+ revision: u32,
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ iw: usize,
+ ih: usize,
+ checksum: u64,
+ };
+ const Capture = struct {
+ fn get(surface: *const Surface) ![2]Snapshot {
+ if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount;
+ var out: [2]Snapshot = undefined;
+ for (&out, 0..) |*snapshot, i| {
+ const place = surface.images[i] orelse return error.MissingVisiblePdf;
+ snapshot.* = .{
+ .page = place.native.page,
+ .revision = place.native.revision,
+ .geometry = place.native.geometry orelse return error.MissingPdfGeometry,
+ .pixel_offset_y = place.native.pixel_offset_y,
+ .iw = place.iw,
+ .ih = place.ih,
+ .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba),
+ };
+ }
+ return out;
+ }
+
+ fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void {
+ for (before, after) |old, new| {
+ try std.testing.expectEqual(old.page, new.page);
+ try std.testing.expectEqual(old.geometry, new.geometry);
+ try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y);
+ try std.testing.expectEqual(old.iw, new.iw);
+ try std.testing.expectEqual(old.ih, new.ih);
+ }
+ }
+ };
+ const default_filtered = try Capture.get(default_surface);
+ for (pv.rasters[0..pv.rasters_len]) |raster|
+ try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode);
+
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ p.setThemeIndex(dark_index);
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const themed_surface = try p.render(frame.allocator());
+ const themed = try Capture.get(themed_surface);
+ try Capture.expectGeometry(default_filtered, themed);
+ for (default_filtered, themed) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ try std.testing.expect(new.checksum != old.checksum);
+ }
+
+ // Chrome animation reads a separate palette. Its ticks must never disturb
+ // the target-theme tint key or ask MuPDF for the same pixels again.
+ try std.testing.expect(p.animationActive());
+ for (0..layout.Animation.transition_steps) |_| {
+ p.update(.tick);
+ _ = frame.reset(.retain_capacity);
+ const tick_surface = try p.render(frame.allocator());
+ const ticked = try Capture.get(tick_surface);
+ try Capture.expectGeometry(themed, ticked);
+ for (themed, ticked) |once, after_tick| {
+ try std.testing.expectEqual(once.revision, after_tick.revision);
+ try std.testing.expectEqual(once.checksum, after_tick.checksum);
+ }
+ }
+ try std.testing.expect(!p.animationActive());
+
+ panes.Pdf.toggleTint(pane);
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+ _ = frame.reset(.retain_capacity);
+ const full_surface = try p.render(frame.allocator());
+ const full = try Capture.get(full_surface);
+ try Capture.expectGeometry(themed, full);
+ for (themed, full) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ }
+ for (pv.rasters[0..pv.rasters_len]) |raster|
+ try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode);
+
+ panes.Pdf.toggleTint(pane);
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ _ = frame.reset(.retain_capacity);
+ const disabled_surface = try p.render(frame.allocator());
+ const disabled = try Capture.get(disabled_surface);
+ try Capture.expectGeometry(full, disabled);
+ for (full, disabled) |old, source| {
+ try std.testing.expect(source.revision > old.revision);
+ try std.testing.expect(source.checksum != old.checksum);
+ }
+
+ const acme_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "acme")) break i;
+ } else return error.MissingAcmeTheme;
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
+ p.setThemeIndex(acme_index);
+ // Disabled keys ignore theme colors, so neither explicit invalidation nor
+ // the per-raster key comparison asks MuPDF for replacement pixels.
+ for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const unchanged_surface = try p.render(frame.allocator());
+ const unchanged = try Capture.get(unchanged_surface);
+ try Capture.expectGeometry(disabled, unchanged);
+ for (disabled, unchanged) |old, new| {
+ try std.testing.expectEqual(old.revision, new.revision);
+ try std.testing.expectEqual(old.checksum, new.checksum);
+ }
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
+}
+
+test "PDF resize preserves page-relative document position" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ panes.Pdf.setPage(p, pane, 1);
+ _ = try p.render(frame.allocator());
+ const fraction: f64 = 0.375;
+ pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) +
+ fraction * @as(f64, @floatFromInt(pv.page_heights[1]));
+ p.update(.{ .resize = .{
+ .cols = 100,
+ .rows = 24,
+ .cell_pixels = p.cell_pixels,
+ } });
+ try std.testing.expect(!pv.layout_valid);
+ try std.testing.expect(pv.layout_anchor_pending);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const resized_fraction = (pv.document_scroll_y -
+ @as(f64, @floatFromInt(pv.page_starts[1]))) /
+ @as(f64, @floatFromInt(pv.page_heights[1]));
+ try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001);
+ try std.testing.expectEqual(@as(usize, 1), pv.page);
+
+ const held = pv.document_scroll_y;
+ p.update(.{ .resize = .{
+ .cols = 100,
+ .rows = 24,
+ .cell_pixels = p.cell_pixels,
+ } });
+ try std.testing.expectEqual(held, pv.document_scroll_y);
+ try std.testing.expect(pv.layout_valid);
+}
+
+test "PDF pane geometry change preserves its page-relative position" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ // A FILE boot is one pane now and a lone column always fills the window,
+ // so the divider drag below needs a second column to take the width from.
+ _ = try p.newShell(1, "");
+ _ = layout.splitColumn(p, 0, 1, false);
+ pardes.test_api.sync(p); // rects for the new column, exactly as the two-pane boot did
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ panes.Pdf.setPage(p, pane, 1);
+ _ = try p.render(frame.allocator());
+ const fraction: f64 = 0.625;
+ pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) +
+ fraction * @as(f64, @floatFromInt(pv.page_heights[1]));
+ const old_width = pv.layout_viewport_w;
+
+ // A divider/split changes rects through computeGeom without emitting a
+ // shell resize. panes.Pdf.ensurePaneLayout anchors against the old layout
+ // before rebuilding it for this wider pane.
+ const sibling_weight = p.col_weight[1];
+ p.col_weight[0] = sibling_weight * 6;
+ layout.compute(p);
+ try std.testing.expect(panes.Pdf.paneViewport(p, pane).?.pixel_w != old_width);
+ try std.testing.expect(pv.layout_valid);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ const changed_fraction = (pv.document_scroll_y -
+ @as(f64, @floatFromInt(pv.page_starts[1]))) /
+ @as(f64, @floatFromInt(pv.page_heights[1]));
+ try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001);
+ try std.testing.expectEqual(@as(usize, 1), pv.page);
+
+ // An explicit pending reveal wins over an implicit layout anchor.
+ pv.page = 2;
+ pv.scroll_to_page_pending = true;
+ p.col_weight[0] = sibling_weight * 2;
+ layout.compute(p);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ @as(f64, @floatFromInt(pv.page_starts[2])),
+ pv.document_scroll_y,
+ );
+}
+
+test "PDF n/N addresses and reveals distinct MuPDF hits on one page" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 120,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+
+ var page_hits = try pv.document.search(gpa, 0, "Pardes");
+ defer page_hits.deinit(gpa);
+ try std.testing.expect(page_hits.hit_count >= 3);
+
+ try p.runSearch(0, "Pardes", .text, .top);
+ const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
+ const rows = p.panes[results_id].?.file.?.content;
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null);
+
+ // Exaggerate the cell aspect only to make the three fixture hits occupy
+ // distinct fit-width crops. The search/reveal math must use the same
+ // reported metrics as placement, whatever a backend reports.
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = .{ .w = 16, .h = 4 },
+ } });
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ try std.testing.expect(pardes.test_api.searchStep(p, 0, 1));
+ try std.testing.expectEqual(@as(usize, 0), pv.search_hit);
+ const first = try p.render(frame.allocator());
+ const revision = first.images[0].?.native.revision;
+ const raster = panes.Pdf.rasterForPage(pv, pv.page).?;
+ const first_scroll = pv.document_scroll_y;
+ const first_results = pv.search_results orelse return error.MissingPdfPageSearch;
+ const first_quad = for (first_results.quads) |item| {
+ if (item.hit == 0) break item.quad;
+ } else return error.MissingFirstPdfHit;
+ const first_y = panes.Pdf.normalizedPixel(
+ (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4,
+ raster.ih,
+ );
+ const first_geometry = panes.Pdf.paneGeometry(p, pane) orelse return error.MissingPdfGeometry;
+ try std.testing.expect(first_y >= first_geometry.src.y and
+ first_y < first_geometry.src.y + first_geometry.src.h);
+
+ try std.testing.expect(pardes.test_api.searchStep(p, 0, 1));
+ try std.testing.expectEqual(@as(usize, 1), pv.search_hit);
+ _ = frame.reset(.retain_capacity);
+ const second = try p.render(frame.allocator());
+ try std.testing.expectEqual(revision, second.images[0].?.native.revision);
+
+ try std.testing.expect(pardes.test_api.searchStep(p, 0, 1));
+ try std.testing.expectEqual(@as(usize, 2), pv.search_hit);
+ _ = frame.reset(.retain_capacity);
+ const third = try p.render(frame.allocator());
+ try std.testing.expectEqual(revision, third.images[0].?.native.revision);
+ try std.testing.expect(pv.document_scroll_y != first_scroll);
+ const third_results = pv.search_results orelse return error.MissingPdfPageSearch;
+ const third_quad = for (third_results.quads) |item| {
+ if (item.hit == 2) break item.quad;
+ } else return error.MissingThirdPdfHit;
+ const third_y = panes.Pdf.normalizedPixel(
+ (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4,
+ raster.ih,
+ );
+ const third_geometry = panes.Pdf.paneGeometry(p, pane) orelse return error.MissingPdfGeometry;
+ try std.testing.expect(third_y >= third_geometry.src.y and
+ third_y < third_geometry.src.y + third_geometry.src.h);
+}
+
+test "PDF native mouse selection, Look, and highlights share page geometry" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ // A host without Kitty/native pixels keeps the projected-text contract:
+ // j/k can still change pages and a body drag remains a generic selection.
+ const fallback = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), fallback.nimages);
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page);
+ const fallback_rect = p.rects[0];
+ const fallback_col = fallback_rect.x + config.GUTTER + 1;
+ const fallback_row = fallback_rect.y + BOX_H + 1;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } });
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+ try std.testing.expect(pane.pdf.?.selection == null);
+ pane.sel[sel_slot].state = .none;
+
+ p.native_images = true;
+ _ = frame.reset(.retain_capacity);
+ const plain = try p.render(frame.allocator());
+ const plain_revision = plain.images[0].?.native.revision;
+
+ var found = try pane.pdf.?.document.search(gpa, 0, "Pardes");
+ defer found.deinit(gpa);
+ const quad = found.quads[0].quad;
+ const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4;
+ const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4;
+ const pv = &pane.pdf.?;
+ const raster = panes.Pdf.rasterForPage(pv, pv.page).?;
+ const source_x: u32 = @intCast(@min(
+ raster.iw - 1,
+ @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(raster.iw)))),
+ ));
+ const source_y: u32 = @intCast(@min(
+ raster.ih - 1,
+ @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(raster.ih)))),
+ ));
+
+ // Bring the known word into the fit-width crop, then invert the shared
+ // source/destination geometry to the nearest body cell.
+ var geometry = panes.Pdf.paneGeometry(p, pane).?;
+ if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) {
+ const overflow = @as(u32, @intCast(raster.ih)) - geometry.src.h;
+ const wanted = @min(overflow, source_y -| geometry.src.h / 2);
+ pv.pan_y = if (overflow == 0) 0 else @intCast(
+ (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
+ );
+ geometry = panes.Pdf.paneGeometry(p, pane).?;
+ }
+ const pixel_x = geometry.dst.x + @as(u32, @intCast(
+ @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w,
+ ));
+ const pixel_y = geometry.dst.y + @as(u32, @intCast(
+ @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h,
+ ));
+ const r = p.rects[0];
+ const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w);
+ const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h);
+
+ var selected_col: ?u16 = null;
+ var selected_row: u16 = 0;
+ const nearby = [_]i32{ 0, -1, 1, -2, 2 };
+ find_word: for (nearby) |dy| for (nearby) |dx| {
+ const col: u16 = @intCast(std.math.clamp(
+ base_col + dx,
+ @as(i32, r.x + config.GUTTER),
+ @as(i32, r.x + r.w - 1),
+ ));
+ const row: u16 = @intCast(std.math.clamp(
+ base_row + dy,
+ @as(i32, r.y + BOX_H),
+ @as(i32, r.y + r.h - 1),
+ ));
+ if (!panes.Pdf.beginSelection(p, pane, col, row)) continue;
+ if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) {
+ selected_col = col;
+ selected_row = row;
+ break :find_word;
+ }
+ };
+ const word_col = selected_col orelse return error.PdfMouseMappingMissedWord;
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(
+ pardes.test_api.heldSelection(p, 0).?,
+ "Pardes",
+ ) != null);
+
+ _ = frame.reset(.retain_capacity);
+ const selected_frame = try p.render(frame.allocator());
+ const selected_revision = selected_frame.images[0].?.native.revision;
+ try std.testing.expect(selected_revision != plain_revision);
+
+ // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the
+ // same texture generation instead of retransmitting identical pixels.
+ try std.testing.expect(panes.Pdf.beginSelection(p, pane, word_col, selected_row));
+ try std.testing.expect(raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const duplicate = try p.render(frame.allocator());
+ try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision);
+
+ // Delayed native hover retains independent quads/text and changes only
+ // presentation state. In particular it must not borrow the click path,
+ // whose page activation and persistent selection are intentional effects
+ // of a gesture rather than observation.
+ if (config.look_preview_delay_frames) |delay| {
+ const active_before = p.active;
+ const page_before = pv.page;
+ const scroll_before = pv.document_scroll_y;
+ const sels_before = pane.sel;
+ const cursor_before = .{ pane.cur_row, pane.cur_col };
+ const modal_before = .{ pane.msel, pane.vsel, pane.nsel };
+ const selection_before = pv.selection orelse return error.MissingPdfSelection;
+ const selection_quads_before = try gpa.dupe(pdf_impl.Quad, selection_before.quads);
+ defer gpa.free(selection_quads_before);
+ const selection_text_before = try gpa.dupe(u8, pv.selection_text);
+ defer gpa.free(selection_text_before);
+ const selection_anchor_before = pv.selection_anchor;
+ const selection_head_before = pv.selection_head;
+ const query_before = try gpa.dupe(u8, pv.search_query);
+ defer gpa.free(query_before);
+ const search_hit_before = pv.search_hit;
+ const jumps_before = .{ p.njumps, p.jcur, p.n_look_src };
+ const effects_before = p.effects_len;
+ try std.testing.expect(p.drag == .none);
+
+ const hover_motion = Event{ .mouse = .{
+ .button = .none,
+ .kind = .motion,
+ .col = word_col,
+ .row = selected_row,
+ } };
+ p.update(hover_motion);
+ try std.testing.expect(p.look_hover_wait != null);
+ for (0..delay) |_| p.update(.tick);
+ const hover = p.pdf_hover_preview orelse return error.MissingPdfHoverPreview;
+ try std.testing.expectEqual(@as(usize, 0), hover.probe.page);
+ try std.testing.expect(hover.probe.quads.len > 0);
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(hover.probe.text, "Pardes") != null);
+ try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(p.look_hover_wait == null);
+
+ const decorated = try panes.Pdf.buildHighlights(
+ pv,
+ p.scratch.allocator(),
+ panes.Pdf.highlightInput(p, 0, pane),
+ );
+ const page_highlights = decorated.forPage(pv.page, pv.page);
+ try std.testing.expectEqual(pdf_impl.HighlightKind.custom, page_highlights[0].kind);
+ try std.testing.expectEqual(pdf_impl.HighlightKind.selection, page_highlights[page_highlights.len - 1].kind);
+ _ = frame.reset(.retain_capacity);
+ const hovered_frame = try p.render(frame.allocator());
+ const hovered_revision = hovered_frame.images[0].?.native.revision;
+ try std.testing.expect(hovered_revision != selected_revision);
+
+ p.update(.pointer_leave);
+ try std.testing.expect(p.pdf_hover_preview == null);
+ _ = frame.reset(.retain_capacity);
+ const unhovered_frame = try p.render(frame.allocator());
+ try std.testing.expect(unhovered_frame.images[0].?.native.revision != hovered_revision);
+
+ try std.testing.expectEqual(active_before, p.active);
+ try std.testing.expectEqual(page_before, pv.page);
+ try std.testing.expectEqual(scroll_before, pv.document_scroll_y);
+ try std.testing.expect(std.meta.eql(sels_before, pane.sel));
+ try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col });
+ try std.testing.expect(std.meta.eql(modal_before, .{ pane.msel, pane.vsel, pane.nsel }));
+ const selection_after = pv.selection orelse return error.HoverDroppedPdfSelection;
+ try std.testing.expectEqualSlices(pdf_impl.Quad, selection_quads_before, selection_after.quads);
+ try std.testing.expectEqualStrings(selection_text_before, pv.selection_text);
+ try std.testing.expect(std.meta.eql(selection_anchor_before, pv.selection_anchor));
+ try std.testing.expect(std.meta.eql(selection_head_before, pv.selection_head));
+ try std.testing.expectEqualStrings(query_before, pv.search_query);
+ try std.testing.expectEqual(search_hit_before, pv.search_hit);
+ try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src });
+ try std.testing.expectEqual(effects_before, p.effects_len);
+ try std.testing.expect(p.drag == .none);
+ }
+
+ // A native right-click resolves the same MuPDF-snapped word and feeds it
+ // to Look. Search highlights precede selection highlights so the live
+ // selection remains visually authoritative where they overlap.
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } });
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } });
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null);
+ pv.resolveSearch(p.pdf_gpa);
+ const highlights = (try panes.Pdf.buildHighlights(
+ pv,
+ p.scratch.allocator(),
+ panes.Pdf.highlightInput(p, 0, pane),
+ )).items;
+ try std.testing.expect(highlights.len > 1);
+ try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind);
+ try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind);
+ const revision_before_reveal = raster.revision;
+ const max_document_scroll = @as(f64, @floatFromInt(
+ pv.document_height -| panes.Pdf.paneViewport(p, pane).?.pixel_h,
+ ));
+ pv.document_scroll_y = max_document_scroll;
+ panes.Pdf.revealSearch(pv, panes.Pdf.paneViewport(p, pane).?, panes.Pdf.paneGeometry(p, pane));
+ const revealed_scroll = pv.document_scroll_y;
+ try std.testing.expect(revealed_scroll != max_document_scroll);
+ try std.testing.expectEqual(revision_before_reveal, raster.revision);
+ pv.document_scroll_y = max_document_scroll;
+ panes.Pdf.revealSearch(
+ pv,
+ panes.Pdf.paneViewport(p, pane).?,
+ panes.Pdf.paneGeometry(p, pane),
+ ); // one shot: subsequent manual pan stays put
+ try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y);
+ pv.document_scroll_y = revealed_scroll;
+ _ = frame.reset(.retain_capacity);
+ const searched = try p.render(frame.allocator());
+ try std.testing.expect(searched.images[0].?.native.revision != selected_revision);
+
+ // Fit and viewport changes can crop a hit which was already revealed.
+ // Fit remains placement-only. SDL's physical-viewport quality request
+ // changes with a resize and therefore replaces pixels; Kitty's fixed,
+ // bandwidth-conscious request retains them.
+ const revision_before_geometry_change = searched.images[0].?.native.revision;
+ pv.search_reveal_pending = false;
+ panes.Pdf.toggleFit(pane);
+ try std.testing.expect(pv.search_reveal_pending);
+ pv.search_reveal_pending = false;
+ panes.Pdf.toggleFit(pane); // restore the reading-width geometry
+ try std.testing.expect(pv.search_reveal_pending);
+ pv.search_reveal_pending = false;
+ const original_cell_pixels = p.cell_pixels;
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = p.cell_pixels,
+ } });
+ try std.testing.expect(!pv.search_reveal_pending);
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = .{
+ .w = original_cell_pixels.w,
+ .h = original_cell_pixels.h + 1,
+ },
+ } });
+ try std.testing.expect(pv.search_reveal_pending);
+ _ = frame.reset(.retain_capacity);
+ const geometry_changed = try p.render(frame.allocator());
+ if (pdf_raster_policy.match_viewport)
+ try std.testing.expect(geometry_changed.images[0].?.native.revision !=
+ revision_before_geometry_change)
+ else
+ try std.testing.expectEqual(
+ revision_before_geometry_change,
+ geometry_changed.images[0].?.native.revision,
+ );
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = original_cell_pixels,
+ } });
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+
+ // Unsupported text-selection actions are consumed by the PDF adapter:
+ // they neither invent a range over placeholder cells nor disturb the
+ // native MuPDF selection. The select-button's no-drag click still clears.
+ p.update(.{ .key = .{ .cp = 'v' } });
+ try std.testing.expect(pv.selection != null);
+ try std.testing.expect(!pane.vsel.active);
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } });
+ try std.testing.expect(pv.selection == null);
+ try std.testing.expect(panes.Pdf.beginSelection(p, pane, word_col, selected_row));
+
+ // Native drag events update MuPDF quads and copied text immediately, but
+ // keep the already transmitted page generation stable until release.
+ _ = frame.reset(.retain_capacity);
+ const before_drag = try p.render(frame.allocator());
+ const before_drag_revision = before_drag.images[0].?.native.revision;
+ const drag_col = @min(r.x + r.w - 1, word_col +| 12);
+ const drag_row = @min(r.y + r.h - 1, selected_row +| 4);
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .press,
+ .col = word_col,
+ .row = selected_row,
+ } });
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .drag,
+ .col = @min(drag_col, word_col +| 4),
+ .row = @min(drag_row, selected_row +| 2),
+ } });
+ _ = frame.reset(.retain_capacity);
+ const during_first_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision,
+ during_first_drag.images[0].?.native.revision,
+ );
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .drag,
+ .col = drag_col,
+ .row = drag_row,
+ } });
+ try std.testing.expect(p.drag.select.pdf.selection_changed);
+ _ = frame.reset(.retain_capacity);
+ const during_second_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision,
+ during_second_drag.images[0].?.native.revision,
+ );
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .release,
+ .col = drag_col,
+ .row = drag_row,
+ } });
+ try std.testing.expect(!raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const committed_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision + 1,
+ committed_drag.images[0].?.native.revision,
+ );
+ _ = frame.reset(.retain_capacity);
+ const stable_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ committed_drag.images[0].?.native.revision,
+ stable_drag.images[0].?.native.revision,
+ );
+
+ const saved_query = try gpa.dupe(u8, pv.search_query);
+ defer gpa.free(saved_query);
+ panes.Pdf.setPage(p, pane, 1);
+ try std.testing.expect(pv.selection == null);
+ try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len);
+ try std.testing.expectEqualStrings(saved_query, pv.search_query);
+}
+
+test "Esc back into a PDF keeps the offset within its page" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
+
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 28 } });
+ const doc = p.active;
+ const dp = p.panes[doc].?;
+ try std.testing.expect(dp.pdf != null);
+ pardes.test_api.sync(p);
+
+ // Read a little way DOWN the page you are on, then step away.
+ const pv = &dp.pdf.?;
+ pv.document_scroll_y += 137;
+ const mid = pv.document_scroll_y;
+ pv.scroll_to_page_pending = false;
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
+ pardes.test_api.sync(p);
+ const shell = p.active;
+ try std.testing.expect(shell != doc);
+
+ p.update(.{ .key = .{ .cp = Key.escape } }); // Esc: back into the PDF
+ pardes.test_api.sync(p);
+ try std.testing.expectEqual(doc, p.active);
+
+ // The jumps stack records a PDF as its PAGE, so returning revealed the page
+ // you were already on — and a reveal sets `document_scroll_y` to that page's
+ // start, throwing away where you had read to inside it.
+ try std.testing.expectEqual(mid, pv.document_scroll_y);
+ // Same reveal, the other half: with no valid layout yet it only ARMS the
+ // snap, so a test that watched the offset alone would not see it coming.
+ try std.testing.expect(!pv.scroll_to_page_pending);
+}