diff options
Diffstat (limited to 'test/pdf.zig')
| -rw-r--r-- | test/pdf.zig | 1867 |
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, §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); + 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); +} |
