From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: Refactor panes and filesystem; replace FUSE with 9P Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill. --- src/pdf_pane_integration_test.zig | 1819 ------------------------------------- 1 file changed, 1819 deletions(-) delete mode 100644 src/pdf_pane_integration_test.zig (limited to 'src/pdf_pane_integration_test.zig') diff --git a/src/pdf_pane_integration_test.zig b/src/pdf_pane_integration_test.zig deleted file mode 100644 index f0a8be8f..00000000 --- a/src/pdf_pane_integration_test.zig +++ /dev/null @@ -1,1819 +0,0 @@ -//! End-to-end PDF pane tests. Production state and behavior stay in pdf_pane.zig; -//! this module exercises their direct seam with Pardes layout, input, and output. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const pdf_pane = @import("pdf_pane.zig"); -const file_pane = @import("file_pane.zig"); -const look = @import("look.zig"); -const pdf_impl = pdf_pane.pdf; -const image = @import("image.zig"); -const config = @import("config.zig"); -const builtins = @import("builtins.zig"); -const animation = pardes.animation; -const dump = pardes.dump; - -const Pardes = pardes.Pardes; -const Builtin = builtins.registry.Builtin(); -const Key = pardes.Key; -const Event = pardes.Event; -const Mode = pardes.Mode; -const Surface = pardes.Surface; -const ImagePlace = pardes.ImagePlace; -const PdfFitMode = pdf_pane.FitMode; -const PdfTintMode = pdf_pane.TintMode; -const pdf_enabled = pdf_pane.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 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 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 = &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(pdf_pane.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; - pdf_pane.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 pdf_pane.openPane(p, duplicate_id, path, 0); - p.placeDoc(0, duplicate_id, duplicate); - p.computeGeom(); - pdf_pane.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.pdf_test.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(); - pdf_pane.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; - pdf_pane.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); - pdf_pane.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"); - pdf_pane.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 = pdf_pane.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 = pdf_pane.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 = pdf_pane.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. - file_pane.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. - 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 look.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 = pdf_pane.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.pdf_test.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 = pdf_pane.renderRequest( - pdf_pane.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, - pdf_pane.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 = pdf_pane.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, - pdf_pane.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(@as(u21, 0), pane.pending); - try std.testing.expectEqual(@as(u32, 0), pane.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 = pdf_pane.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, - pdf_pane.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 = pdf_pane.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, - pdf_pane.rasterForPage(&pane.pdf.?, 0).?.revision, - ); - try std.testing.expect(pdf_pane.scrollPane(p, pane, 1)); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); - try std.testing.expectEqual( - page1_revision, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - pdf_pane.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. - pdf_pane.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(pdf_pane.rasterForPage(&pane.pdf.?, 0) == null); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len); - const retired = pane.pdf.?.spare[0]; - pdf_pane.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 = pdf_pane.rasterForPage( - &pane.pdf.?, - pane.pdf.?.page, - ).?.revision; - try std.testing.expect(pardes.pdf_test.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, - pdf_pane.rasterForPage(&pane.pdf.?, pane.pdf.?.page).?.revision, - ); - const height_tag = try pardes.pdf_test.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; - pdf_pane.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 = pdf_pane.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; - pdf_pane.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; - pdf_pane.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. - pdf_pane.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 = pdf_pane.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(pdf_pane.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 = pdf_pane.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 = pdf_pane.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.count); - try std.testing.expectEqual(@as(u21, 0), pane.pending); - 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. - pdf_pane.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( - pdf_pane.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.count); - try std.testing.expectEqual(@as(u21, 0), pane.pending); - // 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 = pdf_pane.ensurePaneLayout(p, pane).?; - const visible = pdf_pane.visiblePages(pv, viewport); - try std.testing.expectEqual(pv.page_count, visible.len); - const request = pdf_pane.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 = pdf_pane.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.pdf_test.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.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.full, pv.tint); - const full_tag = try pardes.pdf_test.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, ""); - _ = p.layoutSplitColumn(0, 1, false); - pardes.pdf_test.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.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 1, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.full, pv.tint); - - try std.testing.expect(pardes.pdf_test.runBuiltin( - p, - @tagName(tint_builtin), - 0, - "", - null, - )); - try std.testing.expectEqual(PdfTintMode.disabled, pv.tint); - const disabled_tag = try pardes.pdf_test.tagText(p, p.scratch.allocator(), pane); - try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null); - try std.testing.expect(pardes.pdf_test.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 = pdf_pane.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 = pdf_pane.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..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()); - - pdf_pane.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); - - pdf_pane.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(); - - pdf_pane.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, ""); - _ = p.layoutSplitColumn(0, 1, false); - pardes.pdf_test.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(); - - pdf_pane.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. pdf_pane.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; - p.computeGeom(); - try std.testing.expect(pdf_pane.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; - p.computeGeom(); - _ = 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.pdf_test.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 = pdf_pane.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 = pdf_pane.normalizedPixel( - (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4, - raster.ih, - ); - const first_geometry = pdf_pane.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.pdf_test.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.pdf_test.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 = pdf_pane.normalizedPixel( - (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4, - raster.ih, - ); - const third_geometry = pdf_pane.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 = pdf_pane.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 = pdf_pane.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 = pdf_pane.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 (!pdf_pane.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.pdf_test.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(pdf_pane.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 pdf_pane.buildHighlights( - pv, - p.scratch.allocator(), - pdf_pane.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 pdf_pane.buildHighlights( - pv, - p.scratch.allocator(), - pdf_pane.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 -| pdf_pane.paneViewport(p, pane).?.pixel_h, - )); - pv.document_scroll_y = max_document_scroll; - pdf_pane.revealSearch(pv, pdf_pane.paneViewport(p, pane).?, pdf_pane.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; - pdf_pane.revealSearch( - pv, - pdf_pane.paneViewport(p, pane).?, - pdf_pane.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; - pdf_pane.toggleFit(pane); - try std.testing.expect(pv.search_reveal_pending); - pv.search_reveal_pending = false; - pdf_pane.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(pdf_pane.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); - pdf_pane.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.pdf_test.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.pdf_test.sync(p); - const shell = p.active; - try std.testing.expect(shell != doc); - - p.update(.{ .key = .{ .cp = Key.escape } }); // Esc: back into the PDF - pardes.pdf_test.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); -} -- cgit v1.3