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.pdf_pane_builtins_str; fn hasPdf(pane: *const pardes.Pane) bool { return if (comptime pdf_enabled) pane.pdf != null else false; } const PdfLinkTests = struct { const Cell = struct { col: u16, row: u16 }; fn init(tmp: std.testing.TmpDir) !*Pardes { const bytes = try pdf_impl.makeLinksTestPdf(std.testing.allocator); defer std.testing.allocator.free(bytes); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "links.pdf", .data = bytes }); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "target.txt", .data = "chosen file\n" }); var dir_buf: [4096]u8 = undefined; const len = try tmp.dir.realPath(std.testing.io, &dir_buf); const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/links.pdf", .{dir_buf[0..len]}); defer std.testing.allocator.free(path); const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 100, .rows = 36 }); p.native_images = true; p.presentation.enabled = false; p.panes[0].?.pdf.?.fit = .height; return p; } fn cellAt(p: *Pardes, x: f32, y: f32) !Cell { const pane = p.panes[0].?; const rect = p.rects[0]; var best: ?Cell = null; var distance: f32 = std.math.inf(f32); for (rect.y..rect.y + rect.h) |row| for (rect.x..rect.x + rect.w) |col| { const point = panes.Pdf.panePointAtPage(p, pane, 0, @intCast(col), @intCast(row), false) orelse continue; const dx = point.x - x; const dy = point.y - y; const d = dx * dx + dy * dy; if (d < distance) { distance = d; best = .{ .col = @intCast(col), .row = @intCast(row) }; } }; return best orelse error.LinkOutsideViewport; } fn textCell(p: *Pardes, text: []const u8) !Cell { var found = try p.panes[0].?.pdf.?.document.search(p.gpa, 0, text); defer found.deinit(p.gpa); if (found.quads.len == 0) return error.MissingLinkFixtureText; const q = found.quads[0].quad; return cellAt(p, (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4); } fn click(p: *Pardes, cell: Cell) void { p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = cell.col, .row = cell.row } }); p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = cell.col, .row = cell.row } }); } fn expectOpened(p: *Pardes, expected: ?[]const u8) !void { var count: usize = 0; while (p.nextEffect()) |effect| { if (effect == .open_link) { count += 1; try std.testing.expectEqualStrings(expected orelse return error.UnexpectedExternalOpen, effect.open_link.slice()); } } try std.testing.expectEqual(@as(usize, if (expected != null) 1 else 0), count); } test "PDF links activate embedded URLs and blank internal annotations" { if (!pdf_enabled or platform == .web) return; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const p = try init(tmp); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); while (p.nextEffect()) |_| {} click(p, try textCell(p, "label")); try expectOpened(p, "https://example.com/only"); try std.testing.expectEqual(@as(usize, 0), p.panes[0].?.pdf.?.page); click(p, try cellAt(p, 0.3, 0.38)); try expectOpened(p, null); try std.testing.expectEqual(@as(usize, 1), p.panes[0].?.pdf.?.page); } test "PDF links offer distinct visible destinations and do not duplicate the same URL" { if (!pdf_enabled or platform == .web) return; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const p = try init(tmp); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); while (p.nextEffect()) |_| {} click(p, try textCell(p, "target.txt")); try expectOpened(p, null); const choices = for (p.panes, 0..) |slot, id| { if (id == 0) continue; if (slot) |pane| if (pane.file) |file| if (file.output != null) break pane; } else return error.MissingPdfLinkChoices; try std.testing.expect(std.mem.indexOf(u8, choices.file.?.content, "https://example.com/embedded") != null); try std.testing.expect(std.mem.indexOf(u8, choices.file.?.content, "target.txt") != null); const choices_id = for (p.panes, 0..) |slot, id| { if (slot == choices) break id; } else return error.MissingPdfLinkChoices; p.lookAt(choices_id, "https://example.com/embedded"); try expectOpened(p, "https://example.com/embedded"); p.lookAt(choices_id, "target.txt"); try std.testing.expectEqualStrings("chosen file\n", p.panes[p.active].?.file.?.content); p.active = 0; _ = try p.render(frame.allocator()); click(p, try textCell(p, "https://example.com/same")); try expectOpened(p, "https://example.com/same"); } fn hover(p: *Pardes, cell: Cell) void { p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = cell.col, .row = cell.row } }); for (0..(config.look_preview_delay_frames orelse 0)) |_| p.update(.tick); } test "PDF links hover clears on leave page and reload while right drag selects text" { if (!pdf_enabled or platform == .web) return; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const p = try init(tmp); defer p.deinit(); const pane = p.panes[0].?; var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); const plain = try p.render(frame.allocator()); const plain_revision = plain.images[0].?.native.revision; while (p.nextEffect()) |_| {} const blank = try cellAt(p, 0.3, 0.38); hover(p, blank); const hovered = try p.render(frame.allocator()); try std.testing.expectEqual(Surface.PointerShape.link, hovered.pointer_shape); try std.testing.expect(hovered.images[0].?.native.revision != plain_revision); const highlights = panes.Pdf.highlightInput(p, 0, pane); try std.testing.expect(!std.meta.eql(highlights.hover_color, highlights.selection_color)); try std.testing.expect(pane.pdf.?.selection == null); try expectOpened(p, null); p.update(.pointer_leave); try std.testing.expectEqual(Surface.PointerShape.arrow, (try p.render(frame.allocator())).pointer_shape); hover(p, blank); panes.Pdf.setPage(p, pane, 1); try std.testing.expectEqual(Surface.PointerShape.arrow, (try p.render(frame.allocator())).pointer_shape); panes.Pdf.setPage(p, pane, 0); _ = try p.render(frame.allocator()); hover(p, try cellAt(p, 0.3, 0.38)); try std.testing.expect(p.reloadWatchedFile(0, &.{})); try std.testing.expectEqual(Surface.PointerShape.arrow, (try p.render(frame.allocator())).pointer_shape); const label = try textCell(p, "label"); p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = label.col, .row = label.row } }); p.update(.{ .mouse = .{ .button = config.look_button, .kind = .drag, .col = label.col + 3, .row = label.row } }); p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = label.col + 3, .row = label.row } }); try expectOpened(p, null); try std.testing.expect(pane.pdf.?.selection != null); try std.testing.expect(pane.pdf.?.selection_text.len > 0); } }; test { _ = PdfLinkTests; } 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, §ions.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); const expected_tag = try std.fmt.allocPrint(p.scratch.allocator(), "{s} [1/{d}]{s}", .{ pane.pdf.?.path, pane.pdf.?.page_count, pane_tail }); try std.testing.expectEqualStrings(expected_tag, initial_tag); 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); const expected_height_tag = try std.fmt.allocPrint(p.scratch.allocator(), "{s} [{d}/{d}]{s}", .{ pane.pdf.?.path, pane.pdf.?.page + 1, pane.pdf.?.page_count, pane_tail }); try std.testing.expectEqualStrings(expected_height_tag, height_tag); 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 without adding tag clutter" { 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.startsWith(u8, initial_tag, pv.path)); try std.testing.expect(std.mem.endsWith(u8, initial_tag, " PdfSections PdfTint Collapse")); try std.testing.expect(std.mem.indexOf(u8, initial_tag, "filtered") == 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.expectEqualStrings(initial_tag, full_tag); try std.testing.expect(std.mem.indexOf(u8, full_tag, "full") == 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.expectEqualStrings(initial_tag, disabled_tag); try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled") == 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); p.locations_config.context = 2; p.locations_config.tscontext = true; try p.runSearch(0, "Pardes", .text, .top); const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; const results = p.panes[results_id].?.file.?; var found: [3]bool = @splat(false); for (results.location_rows, 0..) |row, index| { // PDF locations address page/hit pairs, not binary file lines. Each // address owns one snippet, which must not add a navigation stop. if (index % 2 == 1) { try std.testing.expectEqual(.preview, row.kind); try std.testing.expectEqual(results.location_rows[index - 1].at, row.at); try std.testing.expectEqual(@as(usize, 0), row.code_start); try std.testing.expectEqualStrings("Pardes", pardes.modal.lineSlice(results.content, index)); continue; } try std.testing.expectEqual(.match, row.kind); const line = pardes.modal.lineSlice(results.content, index); try std.testing.expectEqual(line.len, row.code_start); if (!std.mem.endsWith(u8, row.path, "design.pdf") or row.at.line != 1) continue; if (row.at.col < 1 or row.at.col > found.len) continue; found[row.at.col - 1] = true; } for (found) |present| try std.testing.expect(present); // 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); try std.testing.expectEqual(@as(usize, 0), pane.search_row.?); 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); try std.testing.expectEqual(@as(usize, 2), pane.search_row.?); _ = 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); try std.testing.expectEqual(@as(usize, 4), pane.search_row.?); _ = 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); 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), )).forPage(pv.page); 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); const saved_selection = try gpa.dupe(u8, pv.selection_text); defer gpa.free(saved_selection); const selected_page = pv.selection_page; panes.Pdf.setPage(p, pane, 1); try std.testing.expect(pv.selection != null); try std.testing.expectEqual(selected_page, pv.selection_page); try std.testing.expectEqualStrings(saved_selection, pv.selection_text); 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); }