diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1786 |
1 files changed, 1746 insertions, 40 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 956b0e25..86dcb97f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -31,6 +31,8 @@ const file_pane = @import("file_pane.zig"); const output_pane = @import("output_pane.zig"); const builtins = @import("builtins.zig"); pub const config = @import("config.zig"); +pub const pdf_enabled = @import("pardes_config").mupdf; +const pdf_impl = if (pdf_enabled) @import("mupdf") else struct {}; pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); @@ -73,6 +75,512 @@ pub const Loc = struct { col: u32, }; +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 p.tagText(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); + + 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); + + // The default reading view moves one visible cell row without replacing + // the page pixels. Fit/pan are placement state, not texture identity. + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + try std.testing.expect(pane.pdf.?.pan_y > 0); + try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_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.expectEqual(pane.pdf.?.pan_y, panned.images[0].?.native.pan_y); + + const row_pan = pane.pdf.?.pan_y; + pane.pdf.?.pan_y = 0; + p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); + const half_pan = pane.pdf.?.pan_y; + try std.testing.expect(half_pan > row_pan); + pane.pdf.?.pan_y = 0; + p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); + try std.testing.expect(pane.pdf.?.pan_y >= half_pan); + try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision); + + // Crossing the fit-width bottom enters the next page at its top. Crossing + // back enters the previous one at its bottom, ready to keep reading up. + pane.pdf.?.pan_y = std.math.maxInt(u16); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + _ = frame.reset(.retain_capacity); + const second = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), second.nimages); + try std.testing.expect(second.images[0].?.native.revision != first_place.native.revision); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_y); + + // PdfFit exists as a real builtin in this build. It resets placement but + // preserves the current page pixels; fit-height j/k remains page-wise. + const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse + return error.MissingPdfFitBuiltin; + const revision_before_toggle = pane.pdf.?.render_revision; + p.runBuiltin(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, pane.pdf.?.render_revision); + const height_tag = try p.tagText(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; + p.togglePdfFit(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 saved_iw = pane.pdf.?.iw; + const saved_ih = pane.pdf.?.ih; + pane.pdf.?.iw = 2000; + pane.pdf.?.ih = 500; + p.pdfHorizontalWheel(pane, 1); + try std.testing.expect(pane.pdf.?.pan_x > 0); + try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision); + pane.pdf.?.iw = saved_iw; + pane.pdf.?.ih = saved_ih; + pane.pdf.?.pan_x = 0; + + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + + const revision_before_search = pane.pdf.?.render_revision; + p.runSearch(0, "Pardes", .text, .top); + try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); + try std.testing.expectEqual(revision_before_search, pane.pdf.?.render_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); + try std.testing.expect(p.searchStep(0, 1)); + 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"; + p.setPdfSearchQuery(&pane.pdf.?, 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; + p.setPdfPage(pane, next_page); + try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); + + 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); +} + +test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 120, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + + var page_hits = try pv.document.search(gpa, 0, "Pardes"); + defer page_hits.deinit(gpa); + try std.testing.expect(page_hits.hit_count >= 3); + + p.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(p.searchStep(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 first_pan = pv.pan_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 = Pardes.normalizedPdfPixel( + (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4, + pv.ih, + ); + const first_geometry = p.pdfGeometry(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(p.searchStep(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(p.searchStep(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.pan_y != first_pan); + 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 = Pardes.normalizedPdfPixel( + (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4, + pv.ih, + ); + const third_geometry = p.pdfGeometry(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 source_x: u32 = @intCast(@min( + pv.iw - 1, + @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(pv.iw)))), + )); + const source_y: u32 = @intCast(@min( + pv.ih - 1, + @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(pv.ih)))), + )); + + // Bring the known word into the fit-width crop, then invert the shared + // source/destination geometry to the nearest body cell. + var geometry = p.pdfGeometry(pane).?; + if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) { + const overflow = @as(u32, @intCast(pv.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 = p.pdfGeometry(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 (!p.beginPdfSelection(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(p.heldSelection(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(p.beginPdfSelection(pane, word_col, selected_row)); + try std.testing.expect(pv.render_tried); + _ = frame.reset(.retain_capacity); + const duplicate = try p.render(frame.allocator()); + try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision); + + // 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); + p.resolvePdfSearch(pv); + const highlights = try p.pdfHighlights(pv); + 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 = pv.render_revision; + pv.pan_y = std.math.maxInt(u16); + p.revealPdfSearch(pane, pv); + const revealed_pan = pv.pan_y; + try std.testing.expect(revealed_pan != std.math.maxInt(u16)); + try std.testing.expectEqual(revision_before_reveal, pv.render_revision); + pv.pan_y = std.math.maxInt(u16); + p.revealPdfSearch(pane, pv); // one shot: subsequent manual pan stays put + try std.testing.expectEqual(std.math.maxInt(u16), pv.pan_y); + pv.pan_y = revealed_pan; + _ = 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. + // Both rearm the placement-only reveal without forcing new page pixels. + const revision_before_geometry_change = searched.images[0].?.native.revision; + pv.search_reveal_pending = false; + p.togglePdfFit(pane); + try std.testing.expect(pv.search_reveal_pending); + pv.search_reveal_pending = false; + p.togglePdfFit(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()); + 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()); + + // A fresh modal range supersedes native selection, and the select-button's + // no-drag click retains its longstanding "clear selection" contract. + p.update(.{ .key = .{ .cp = 'v' } }); + try std.testing.expect(pv.selection == null); + try std.testing.expect(pane.vsel.active); + pane.vsel.active = false; + try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row)); + 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(p.beginPdfSelection(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(!pv.render_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); + p.setPdfPage(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 "PDF native Exec release may delete its own pane safely" { + 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.?; + pv.selection_text = try gpa.dupe(u8, "Del"); + pv.drag_anchor = .{ .x = 0.5, .y = 0.5 }; + pv.drag_head = pv.drag_anchor; + + p.pdfDragRelease(pane, .{ + .id = 0, + .button = config.exec_button, + .pdf = .{ .native = true }, + }); + try std.testing.expect(p.panes[0] == null); +} + +test "feature-off .pdf argv opens an ordinary file pane" { + if (comptime 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].?; + try std.testing.expect(pane.file != null); + try std.testing.expect(pane.image == null); + try std.testing.expect(!hasPdf(pane)); +} + const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; @@ -89,6 +597,16 @@ const file_pane_tail = " " ++ config.file_pane_builtins_str; // sits with the other two comptime folds (builtin_rows, the topbar check). const Builtin = builtins.Builtin(); +test "PdfFit builtin and leader path follow the MuPDF feature gate" { + const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit"); + try std.testing.expectEqual(pdf_enabled, maybe_fit != null); + if (pdf_enabled) { + const path = config.leader_path.get(maybe_fit.?) orelse + return error.MissingPdfFitLeaderPath; + try std.testing.expectEqualStrings("tz", path); + } +} + /// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN /// declarations rather than a table beside them — a new special key names /// itself here, and a wrong name is impossible because there is only one. The @@ -390,13 +908,50 @@ pub const Cell = struct { } }; +/// One generation of a pixel attachment. `serial` identifies the pane for its +/// whole lifetime; `revision` identifies pixels rendered later by that same +/// pane (for example, a different PDF page or zoom level). Backends must use +/// both: pane slots are reused, while a live pane may replace its pixels. +pub const ImageCacheKey = if (pdf_enabled) struct { + serial: u32, + revision: u32, + + pub fn eql(a: @This(), b: @This()) bool { + return a.serial == b.serial and a.revision == b.revision; + } +} else struct { + serial: u32, + + pub fn eql(a: @This(), b: @This()) bool { + return a.serial == b.serial; + } +}; + +/// Native PDF sizing is deliberately semantic rather than a backend knob. +/// Width mode is the document-reading view (the page may be panned vertically); +/// height mode keeps one whole page-height visible and may pan wide pages. +/// The type remains available to shared core code, while PdfView itself and +/// the builtin which mutates it are compiled away with `-Dmupdf=false`. +const PdfFitMode = if (pdf_enabled) enum { width, height } else void; + +/// Dynamic placement exists only for native PDF pages. Static image panes need +/// only their pane identity, so feature-off builds carry a zero-bit payload. +pub const NativePlacement = if (pdf_enabled) struct { + revision: u32 = 0, + fit: image.NativeFit = .contain, + pan_x: u16 = 0, + pan_y: u16 = 0, +} else struct {}; + /// A pixel image riding the surface: the shell transmits/places it over the /// given cell rect (tty: Kitty graphics; SDL: alpha-blended GPU texture). +/// This is also the backend-neutral transport for rasterized PDF pages. pub const ImagePlace = struct { pane: u8, // cache slot - /// Pane slots are reused. This generation makes a cached GPU texture or + /// Pane slots are reused. This identity makes a cached GPU texture or /// kitty image unambiguously belong to the pane which supplied the bytes. serial: u32, + native: NativePlacement = .{}, x: u16, y: u16, w: u16, @@ -404,8 +959,57 @@ pub const ImagePlace = struct { rgba: []const u8, iw: usize, ih: usize, + + pub fn cacheKey(place: ImagePlace) ImageCacheKey { + if (comptime pdf_enabled) + return .{ .serial = place.serial, .revision = place.native.revision }; + return .{ .serial = place.serial }; + } }; +test "pixel attachment cache key follows both pane lifetime and rendered revision" { + if (comptime pdf_enabled) { + const first = ImageCacheKey{ .serial = 41, .revision = 3 }; + try std.testing.expect(first.eql(.{ .serial = 41, .revision = 3 })); + try std.testing.expect(!first.eql(.{ .serial = 41, .revision = 4 })); + try std.testing.expect(!first.eql(.{ .serial = 42, .revision = 3 })); + } else { + const first = ImageCacheKey{ .serial = 41 }; + try std.testing.expect(first.eql(.{ .serial = 41 })); + try std.testing.expect(!first.eql(.{ .serial = 42 })); + } + try std.testing.expectEqual(pdf_enabled, @hasField(ImageCacheKey, "revision")); + try std.testing.expectEqual(pdf_enabled, @hasField(NativePlacement, "fit")); + try std.testing.expectEqual(pdf_enabled, @hasField(CellPixels, "w")); + if (comptime !pdf_enabled) { + try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfDrag)); + } +} + +test "pixel attachment fit and pan do not invalidate cached pixels" { + if (comptime !pdf_enabled) return; + var place = ImagePlace{ + .pane = 0, + .serial = 41, + .native = .{ .revision = 3 }, + .x = 0, + .y = 0, + .w = 1, + .h = 1, + .rgba = &.{ 0, 0, 0, 255 }, + .iw = 1, + .ih = 1, + }; + const key = place.cacheKey(); + place.native.fit = .width; + place.native.pan_x = 1234; + place.native.pan_y = 65535; + try std.testing.expect(key.eql(place.cacheKey())); +} + /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. pub const Surface = struct { @@ -570,10 +1174,25 @@ fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } +/// Pixel dimensions are meaningful only to the native PDF placement path. +/// Keep resize events layout-only when that path is not part of the build. +pub const CellPixels = if (pdf_enabled) struct { + w: u16 = 8, + h: u16 = 16, +} else struct {}; + pub const Event = union(enum) { key: Key, mouse: Mouse, - resize: struct { cols: u16, rows: u16 }, + resize: struct { + cols: u16, + rows: u16, + /// Physical pixels in one grid cell, present only when native PDF + /// placement is compiled in. Defaults keep headless/core callers + /// useful and give terminals which cannot report pixels the + /// conventional 1:2 cell aspect. + cell_pixels: CellPixels = .{}, + }, output: struct { pane: u8, bytes: []const u8 }, eof: struct { pane: u8 }, /// a language query the shell ran on a worker has finished. `rows` is @@ -784,6 +1403,75 @@ pub const Image = struct { grid_ascii: bool = true, }; +const PdfPoint = if (pdf_enabled) pdf_impl.Point else void; +const PdfHighlight = if (pdf_enabled) pdf_impl.Highlight else void; + +/// A PDF pane is compiled out as a zero-bit `void` field when MuPDF is off. +/// With it enabled, the pane owns one live document, one lazy current-page +/// raster for SDL/Kitty, and one lazy text projection for non-pixel hosts and +/// normal Pardes search semantics. +const PdfView = if (pdf_enabled) struct { + path: []u8, + document: pdf_impl.Document, + page: usize = 0, + page_count: usize, + fit: PdfFitMode = .width, + /// Normalized 0..65535 pan positions. NativeGeometry maps the extrema + /// exactly onto the source overflow, independent of page or cell size. + pan_x: u16 = 0, + pan_y: u16 = 0, + rgba: []u8 = &.{}, + /// Search/selection geometry changed without changing fit or pan. The next + /// draw transactionally regenerates this one RGBA buffer with MuPDF's baked + /// highlights; a second full-page buffer is never retained after the swap. + highlights_dirty: bool = false, + search_reveal_pending: bool = false, + search_results: ?pdf_impl.SearchResults = null, + selection: ?pdf_impl.Selection = null, + selection_text: []u8 = &.{}, + selection_anchor: ?pdf_impl.Point = null, + selection_head: ?pdf_impl.Point = null, + drag_anchor: ?pdf_impl.Point = null, + drag_head: ?pdf_impl.Point = null, + iw: usize = 0, + ih: usize = 0, + render_tried: bool = false, + render_revision: u32 = 0, + text: []u8 = &.{}, + text_tried: bool = false, + text_scroll: usize = 0, + /// Full, untruncated PDF query. +Search records only a display/Look copy; + /// this survives page navigation while page-local quads are rebuilt. + search_query: []u8 = &.{}, + /// Zero-based logical MuPDF hit on the current page. Result rows encode + /// this as Spot.col + 1, letting same-page occurrences reveal distinctly. + search_hit: usize = 0, + /// Last viewport for which search_hit was revealed. Comparing this at + /// draw time covers layout/border changes without treating every frame + /// (or a manual pan) as a reason to snap back to the hit. + reveal_viewport_w: u32 = 0, + reveal_viewport_h: u32 = 0, + reveal_fit: PdfFitMode = .width, + reveal_viewport_valid: bool = false, +} else void; + +const PdfSlot = if (pdf_enabled) ?PdfView else void; + +fn hasPdf(pane: *const Pane) bool { + return if (comptime pdf_enabled) pane.pdf != null else false; +} + +fn hasPdfSelection(pane: *const Pane) bool { + return if (comptime pdf_enabled) + if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false + else + false; +} + +test "feature-off PDF pane state is zero-sized" { + if (!pdf_enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfSlot)); +} + pub const Pane = struct { vt: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, @@ -805,6 +1493,7 @@ pub const Pane = struct { greet: bool = false, file: ?File = null, image: ?Image = null, + pdf: PdfSlot = if (pdf_enabled) null else {}, msel: LineSel = .{}, vsel: CharSel = .{}, /// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary @@ -920,7 +1609,17 @@ pub const Pane = struct { } pub fn isTerminal(pane: *const Pane) bool { - return pane.file == null and pane.image == null; + return pane.file == null and pane.image == null and !hasPdf(pane); + } + + pub fn pdfPath(pane: *const Pane) ?[]const u8 { + if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path; + return null; + } + + pub fn pdfPage(pane: *const Pane) ?usize { + if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.page; + return null; } /// Surface row of shell row `g`. The edit buffer's lines stand in for the @@ -947,6 +1646,7 @@ pub const Pane = struct { /// current scroll offset: file top line, or the scrollback offset pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); + if (comptime pdf_enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset)); } @@ -959,6 +1659,16 @@ pub const Pane = struct { f.scroll = next; f.syntax_dirty = true; } + } else if (hasPdf(pane)) { + if (comptime pdf_enabled) { + const pv = &pane.pdf.?; + const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); + pv.text_scroll = @intCast(std.math.clamp( + @as(i64, @intCast(pv.text_scroll)) + delta, + 0, + max, + )); + } } else { // the vt scrolls in SHELL rows; convert through the edit buffer const off: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); @@ -978,6 +1688,10 @@ pub const Pane = struct { // terminals overshoot harmlessly — the vt clamps at the live bottom var to = pane.cur_row + margin; if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1))); + if (comptime pdf_enabled) { + if (pane.pdf) |pv| + to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); + } pane.scrollBy(@max(0, to - last)); } // the horizontal mirror, files only: keep scroll_off columns of @@ -996,12 +1710,13 @@ pub const Pane = struct { /// the last navigable row: file -> last content line; terminal -> viewport bottom fn lastNavRow(pane: *Pane) i32 { if (pane.file) |*f| return @intCast(file_pane.nlines(pane.gpa, f) -| 1); + if (hasPdf(pane)) return @intCast(modal.lineCount(if (comptime pdf_enabled) pane.pdf.?.text else "") -| 1); return pane.scroll() + @as(i32, pane.rows) - 1; } fn pinCursor(pane: *Pane) void { if (pane.cur_pinned) return; - if (pane.file != null) { + if (pane.file != null or hasPdf(pane)) { pane.cur_row = pane.scroll(); pane.cur_col = 0; } else { @@ -1013,6 +1728,13 @@ pub const Pane = struct { } }; +const PdfDrag = if (pdf_enabled) struct { + native: bool = false, + /// Drag updates keep selection geometry/text live, but their expensive + /// baked raster highlight is committed once on release. + selection_changed: bool = false, +} else struct {}; + const Drag = union(enum) { none, /// `corner_idx` is what makes this a CORNER grab: the press landed on a @@ -1042,7 +1764,15 @@ const Drag = union(enum) { tag: struct { id: usize }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert - select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false }, + select: struct { + id: usize, + button: Mouse.Button, + chorded: bool = false, + ctrl: bool = false, + /// This gesture began over a usable native PDF raster. The payload is + /// zero-bit when PDF support is absent. + pdf: PdfDrag = .{}, + }, }; // The two border clamps, pulled out as plain arithmetic on plain numbers for @@ -1140,6 +1870,7 @@ pub const Pardes = struct { opts: Options, screen_w: u16, screen_h: u16, + cell_pixels: CellPixels = .{}, resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), @@ -1263,10 +1994,13 @@ pub const Pardes = struct { if (opts.file) |path| { // FILE argv boot: the doc focused in the left column, a terminal // on the launch directory in the right (files left, shells right) - _ = if (look.isImagePath(path)) - try p.openImageView(0, path) - else - try file_pane.open(p, 0, path, opts.file_line); + _ = initial_doc: { + if (comptime pdf_enabled) if (look.isPdfPath(path)) + break :initial_doc try p.openPdfView(0, path, opts.file_line); + if (look.isImagePath(path)) + break :initial_doc try p.openImageView(0, path); + break :initial_doc try file_pane.open(p, 0, path, opts.file_line); + }; const sh = try p.newPane(1); sh.greet = true; p.ncol = 2; @@ -1350,6 +2084,18 @@ pub const Pardes = struct { if (iv.raw.len > 0) p.gpa.free(iv.raw); if (iv.grid.len > 0) p.gpa.free(iv.grid); } + if (comptime pdf_enabled) { + if (pane.pdf) |*pv| { + p.gpa.free(pv.path); + if (pv.rgba.len > 0) p.gpa.free(pv.rgba); + if (pv.text.len > 0) p.gpa.free(pv.text); + if (pv.search_query.len > 0) p.gpa.free(pv.search_query); + if (pv.search_results) |*results| results.deinit(p.gpa); + if (pv.selection) |*selection| selection.deinit(p.gpa); + if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text); + pv.document.deinit(); + } + } if (pane.file) |*f| { // a watched file is going away: tell the shell to drop it. The id // comes from the slot, which every caller still has pointing at @@ -1476,8 +2222,31 @@ pub const Pardes = struct { pub fn update(p: *Pardes, ev: Event) void { switch (ev) { .resize => |sz| { - p.screen_w = sz.cols; - p.screen_h = sz.rows; + if (comptime pdf_enabled) { + var before: [MAX_PANES]?PdfViewport = @splat(null); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + if (pane.pdf != null) before[id] = p.pdfViewport(pane); + } + p.screen_w = sz.cols; + p.screen_h = sz.rows; + p.cell_pixels.w = @max(1, sz.cell_pixels.w); + p.cell_pixels.h = @max(1, sz.cell_pixels.h); + // Compare the effective per-pane pixel viewport, not the + // resize event itself: duplicate SIGWINCH notifications + // must not undo a reader's manual pan. + p.computeGeom(); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + if (pane.pdf) |*pv| { + if (!std.meta.eql(before[id], p.pdfViewport(pane))) + pv.search_reveal_pending = pv.search_query.len > 0; + } + } + } else { + p.screen_w = sz.cols; + p.screen_h = sz.rows; + } p.resize_count += 1; }, .output => |o| { @@ -1557,6 +2326,11 @@ pub const Pardes = struct { /// pane's. fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); + if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( + arena, + "pdf {d}/{d} {s} PdfFit {s}", + .{ pv.page + 1, pv.page_count, @tagName(pv.fit), pv.path }, + ); if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path}); if (pane.file) |f| return arena.dupe(u8, f.path); return arena.dupe(u8, pane.cwdSlice()); @@ -1943,6 +2717,8 @@ pub const Pardes = struct { /// the active modal selection as text (v range or x lines), if any fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 { + if (comptime pdf_enabled) if (p.native_images) if (pane.pdf) |pv| + if (pv.selection != null and pv.selection_text.len > 0) return pv.selection_text; if (pane.vsel.active) return p.vselText(pane); if (!pane.msel.active) return null; return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); @@ -1965,6 +2741,10 @@ pub const Pardes = struct { }; if (k > 0 and (i == first or (k > 1 and i == p.active))) continue; const t = p.panes[i] orelse continue; + if (comptime pdf_enabled) if (p.native_images) if (t.pdf) |pv| { + if (pv.selection != null and pv.selection_text.len > 0) + return pv.selection_text; + }; if (t.sel[sel_slot].state == .done) { if (p.selectionText(t, t.sel[sel_slot]) catch null) |tx| return tx; } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { @@ -2097,7 +2877,8 @@ pub const Pardes = struct { if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; pane.pinCursor(); - const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + const explicit = (p.native_images and hasPdfSelection(pane)) or + (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { if (p.currentSelText(pane)) |txt| { pane.vsel.active = false; @@ -2113,6 +2894,7 @@ pub const Pardes = struct { p.runBuiltin(cmd, p.active, "", word); return; } + if (pane.mode == .normal and p.pdfNavigationKey(pane, key)) return; // normal-mode `p`/`P` paste the core's yank register directly (helix: // the default register, NOT the system clipboard — most terminals // refuse the OSC 52 read, so a round trip would never come back). @@ -2127,7 +2909,18 @@ pub const Pardes = struct { return; } switch (pane.mode) { - .normal => p.handleNormal(pane, key), + .normal => { + const id = p.active; + const serial = pane.serial; + p.handleNormal(pane, key); + // A new keyboard/modal range supersedes a mouse-native PDF + // selection. Otherwise currentSelText would keep returning the + // stale MuPDF text and make v/x appear to select nothing. + const live = p.panes[id] orelse return; + if (live.serial == serial and hasPdfSelection(live) and + (live.vsel.active or live.msel.active or live.nsel > 0)) + p.clearPdfSelection(live); + }, .insert => { if (hit(key, config.escape)) { pane.mode = .normal; @@ -2400,6 +3193,16 @@ pub const Pardes = struct { row0: i32, // absolute row of lines[0] }; + fn ensurePdfText(p: *Pardes, pane: *Pane) ![]const u8 { + if (comptime !pdf_enabled) return error.PdfDisabled; + const pv = &(pane.pdf orelse return error.NotPdf); + if (!pv.text_tried) { + pv.text_tried = true; + pv.text = pv.document.pageText(p.gpa, pv.page) catch &.{}; + } + return pv.text; + } + /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the whole history+active grid with the edit buffer's lines /// standing in for the rows it covers, so motions ride the scrollback and @@ -2421,6 +3224,13 @@ pub const Pardes = struct { } return .{ .lines = ls, .row0 = 0 }; } + if (hasPdf(pane)) { + const text = try p.ensurePdfText(pane); + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |line| try ls.append(arena, line); + return .{ .lines = ls.items, .row0 = 0 }; + } const rows = try term_pane.shellRows(p, pane); // the buffer may sit below the dump's last row (the grid's trailing // blanks are trimmed off), so run the loop to whichever ends later @@ -2463,6 +3273,7 @@ pub const Pardes = struct { /// cursor-lines dump (scratch-arena backed, same lifetime as pl) fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { if (pane.file) |f| return f.content; + if (hasPdf(pane)) return p.ensurePdfText(pane); const arena = p.scratch.allocator(); var buf: std.ArrayList(u8) = .empty; for (pl.lines, 0..) |ln, i| { @@ -3708,6 +4519,76 @@ pub const Pardes = struct { // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- const Search = enum { text, find, grep }; + const SearchStart = enum { top, cursor }; + + fn paneDir(pane: *const Pane) []const u8 { + if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; + if (comptime pdf_enabled) if (pane.pdf) |pv| + return std.fs.path.dirname(pv.path) orelse "/"; + return pane.cwdSlice(); + } + + const PdfSearchResult = struct { rows: usize = 0, anchor: ?usize = null }; + + /// Ask MuPDF for each page's logical hits, so the result list and the + /// quads used to highlight/reveal can never disagree. Locations spell + /// `path:PAGE:HIT`, reusing Spot.col as a one-based page-local hit id. + /// The compact snippet is the query itself: pairing MuPDF geometry back to + /// extracted lines would be heuristic, and a plausible wrong line is less + /// honest than the exact occurrence identity already in the address. + fn searchPdf( + p: *Pardes, + pane: *Pane, + pat: []const u8, + start: SearchStart, + out: *std.ArrayList(u8), + ) PdfSearchResult { + if (comptime !pdf_enabled) return .{}; + const pv = &(pane.pdf orelse return .{}); + p.setPdfSearchQuery(pv, pat); + const arena = p.scratch.allocator(); + const shown = std.fs.path.basename(pv.path); + var result: PdfSearchResult = .{}; + const max_hits = 512; + var snippet_len = @min(pat.len, 200); + while (snippet_len > 0 and snippet_len < pat.len and pat[snippet_len] & 0xc0 == 0x80) + snippet_len -= 1; + const snippet = pat[0..snippet_len]; + for (0..pv.page_count) |page| { + if (result.rows >= max_hits) break; + var found = pv.document.search(p.gpa, page, pat) catch continue; + defer found.deinit(p.gpa); + + // A native right-click selection identifies the occurrence the + // user is already on. Compare hit centers against its oriented + // selection quads; the next step then advances past that hit. + const cursor_hit: ?usize = if (start == .cursor and page == pv.page) cursor: { + const selection = pv.selection orelse break :cursor null; + for (found.quads) |item| { + const q = item.quad; + const center: PdfPoint = .{ + .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, + .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4, + }; + if (selection.contains(center)) break :cursor item.hit; + } + break :cursor null; + } else null; + + for (0..found.hit_count) |hit_index| { + if (result.rows >= max_hits) break; + if (start == .cursor and + (page < pv.page or (page == pv.page and cursor_hit != null and hit_index <= cursor_hit.?))) + result.anchor = result.rows; + const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ + shown, page + 1, hit_index + 1, snippet, + }) catch return result; + out.appendSlice(arena, row) catch return result; + result.rows += 1; + } + } + return result; + } /// `/` (and the Find builtin) on any pane: type the pattern into the tag /// tail after `marker` — the existing modal one-line editor, visible while @@ -3778,14 +4659,14 @@ pub const Pardes = struct { /// there and the first step goes to the next one (acme's button-3 walking a /// word). `/`, Find and Grep point at nothing, so their list is walked from /// the top, which is also the only place a list of OTHER files could start. - pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: enum { top, cursor }) void { + pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; const arena = p.scratch.allocator(); // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const dir = paneDir(pane); var out: std.ArrayList(u8) = .empty; var nrows: usize = 0; var anchor: ?usize = null; @@ -3799,7 +4680,7 @@ pub const Pardes = struct { var nroots: usize = 0; for (p.panes) |slot| { const pp = slot orelse continue; - const d = if (pp.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pp.cwdSlice(); + const d = paneDir(pp); var covered = false; var k: usize = 0; while (k < nroots) { @@ -3819,7 +4700,11 @@ pub const Pardes = struct { nroots += 1; } for (roots[0..nroots]) |r| look.grep(arena, p.gpa, r, dir, pat, &out); - } else if (kind == .find) look.find(arena, dir, pat, &out) else { + } else if (kind == .find) look.find(arena, dir, pat, &out) else if (hasPdf(pane)) { + const found = p.searchPdf(pane, pat, start, &out); + nrows = found.rows; + anchor = found.anchor; + } else { const pl = p.paneCursorLines(pane) catch return; // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| f.output == null else false; @@ -4104,10 +4989,10 @@ pub const Pardes = struct { /// you click anywhere and type there, which is what the insertion runs /// did by padding at render time). Insert-mode callers pass their column; /// everything else passes -1 and leaves the text alone. - /// Null if the pane holds no editable text (an image). + /// Null if the pane holds no editable text (an image or PDF projection). fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; - if (pane.image != null) return null; + if (pane.image != null or hasPdf(pane)) return null; const want_lo = @max(0, @min(lo, hi)); const want_hi = @max(want_lo, @max(lo, hi)); const fresh = pane.ovl == null; @@ -4176,6 +5061,7 @@ pub const Pardes = struct { const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { + if (hasPdf(pane)) return; pane.pinCursor(); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected @@ -5509,7 +6395,11 @@ pub const Pardes = struct { // the answer is never in doubt here _ = config.wheelTick(&p.wheel_guard, true); const id = hovered orelse return; - p.panes[id].?.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); + const pane = p.panes[id].?; + if (hasPdf(pane)) + p.pdfVerticalWheel(pane, if (m.button == .wheel_up) -1 else 1) + else + pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { if (m.kind != .press) return; @@ -5520,10 +6410,13 @@ pub const Pardes = struct { if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; - // ponytail: no right clamp — overscroll shows blank and the - // next cursor move or left wheel pulls it back - if (pane.file != null) + if (hasPdf(pane)) { + p.pdfHorizontalWheel(pane, if (m.button == .wheel_right) 1 else -1); + // ponytail: no right clamp — overscroll shows blank and the + // next cursor move or left wheel pulls it back + } else if (pane.file != null) { pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols)); + } }, config.select_button => switch (m.kind) { .press => { @@ -5609,11 +6502,18 @@ pub const Pardes = struct { const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; + const pdf_native = if (comptime pdf_enabled) p.pdfNativeReady(pane) else false; + if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, false); // Ctrl rides on the drag rather than firing here: the // click does not place the modal cursor until RELEASE // (dragRelease), and a goto asked at press time would // answer about wherever the cursor happened to be. - p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl } }; + p.drag = .{ .select = .{ + .id = id, + .button = config.select_button, + .ctrl = m.ctrl, + .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{}, + } }; } }, .drag => p.dragUpdate(mcol, mrow), @@ -5664,7 +6564,21 @@ pub const Pardes = struct { const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }; - p.drag = .{ .select = .{ .id = id, .button = m.button } }; + // Once a body gesture starts over a native PDF, keep + // it native even when the point misses a letterbox or + // selection fails. pdfDragRelease makes a null anchor + // inert instead of executing hidden projected text. + // Tag clicks remain ordinary text/builtin gestures. + const pdf_native = if (comptime pdf_enabled) + mrow >= r.y + BOX_H and p.pdfNativeReady(pane) + else + false; + if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, true); + p.drag = .{ .select = .{ + .id = id, + .button = m.button, + .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{}, + } }; } }, .drag => p.dragUpdate(mcol, mrow), @@ -5708,12 +6622,18 @@ pub const Pardes = struct { d.cur_x = mcol; d.cur_y = mrow; }, - .select => |s| { + .select => |*s| { if (p.panes[s.id]) |pane| { const r = p.rects[s.id]; const b = @intFromEnum(s.button); pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); + if (comptime pdf_enabled) { + if (s.pdf.native) { + if (p.updatePdfSelection(pane, mcol, mrow, false)) + s.pdf.selection_changed = true; + } + } } }, .tag => |d| { @@ -5730,6 +6650,50 @@ pub const Pardes = struct { } } + fn pdfDragRelease(p: *Pardes, pane: *Pane, s: @FieldType(Drag, "select")) void { + if (comptime !pdf_enabled) return; + const pv = &(pane.pdf orelse return); + const b = @intFromEnum(s.button); + const sl = pane.sel[b]; + pane.sel[b].state = .none; // native quads, not the projected text grid + // Every native drag keeps its geometry/text live while holding the + // baked highlight stable. Commit before chord/Exec/Look dispatch; + // those paths may clear or even delete the pane. + if (s.pdf.selection_changed) p.invalidatePdfRaster(pv); + if (s.chorded) { + clearPdfDrag(pv); + return; + } + if (s.button == config.select_button) { + const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; + if (!dragged) { + p.clearPdfSelection(pane); + } + pane.cur_pinned = true; + pane.mode = .normal; + pane.msel.active = false; + pane.vsel.active = false; + pane.pending = 0; + pane.nsel = 0; + clearPdfDrag(pv); + return; + } + + if (pv.drag_anchor == null or pv.selection_text.len == 0) { + clearPdfDrag(pv); + return; + } + const text = pv.selection_text; + // Exec can run Del and free `pane`; end every pv access before the + // builtin dispatch so no deferred cleanup writes through dead state. + clearPdfDrag(pv); + const arg = p.chord_arg; + p.chord_arg = null; + defer if (arg) |a| p.gpa.free(a); + const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; + p.runBuiltin(cmd, s.id, "", p.withArg(text, arg)); + } + fn dragRelease(p: *Pardes, button: Mouse.Button) void { // a release of a button OTHER than the one driving a select drag is // the middle+left chord's left release: the middle drag keeps going @@ -5765,6 +6729,9 @@ pub const Pardes = struct { }, .select => |s| { const pane = p.panes[s.id] orelse return; + if (comptime pdf_enabled) { + if (s.pdf.native) return p.pdfDragRelease(pane, s); + } if (s.chorded) { // a cut/paste chord consumed this drag; the release is inert pane.sel[@intFromEnum(s.button)].state = .none; @@ -5893,6 +6860,16 @@ pub const Pardes = struct { fn chordCutPaste(p: *Pardes, cut: bool) void { const s = &p.drag.select; const pane = p.panes[s.id] orelse return; + if (comptime pdf_enabled) if (pane.pdf) |pv| { + if (!s.chorded) { + s.chorded = true; + pane.sel[sel_slot].state = .none; + } + // A PDF is immutable: its Cut-side chord is Snarf (copy), while + // Paste has no document mutation to perform. + if (cut and pv.selection_text.len > 0) p.setYank(pv.selection_text); + return; + }; if (pane.mode == .tty) { // tty: nothing can be cut — 1-2 stays inert; the pty owns the // screen. 1-3 pastes like a terminal emulator: the click is @@ -6143,7 +7120,7 @@ pub const Pardes = struct { const src = p.panes[src_id] orelse return; const src_h = p.rects[src_id].h; const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; - const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1; + const cur: u16 = if (!src.isTerminal()) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1; // cap keep so a content-full source still leaves the new pane a tag + // a few body rows (an Alt-n from a full shell was born 0 rows tall) const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); @@ -6185,7 +7162,7 @@ pub const Pardes = struct { var tty_id: usize = 0; for (0..p.col_n[col]) |k| { const cid = p.col_terms[col][k]; - if (p.panes[cid]) |ct| if (ct.file == null) { + if (p.panes[cid]) |ct| if (ct.isTerminal()) { n_tty += 1; tty_id = cid; }; @@ -6206,7 +7183,7 @@ pub const Pardes = struct { /// focus (execute keeps you where you were; look focuses). fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { - if (tt.file != null or tt.image != null) continue; + if (!tt.isTerminal()) continue; if (std.mem.eql(u8, tt.cwdSlice(), dir)) return i; }; const free = p.freeSlot() orelse return null; @@ -6266,6 +7243,579 @@ pub const Pardes = struct { return pane; } + fn openPdfView(p: *Pardes, id: usize, path: []const u8, page_one_based: usize) !*Pane { + if (comptime !pdf_enabled) return error.PdfDisabled; + var document = try pdf_impl.Document.open(path); + errdefer document.deinit(); + const path_copy = try p.gpa.dupe(u8, path); + errdefer p.gpa.free(path_copy); + const pane = try p.newDocPane(id); + const page = if (page_one_based > 0) + @min(page_one_based - 1, document.pages - 1) + else + 0; + pane.pdf = .{ + .path = path_copy, + .document = document, + .page = page, + .page_count = document.pages, + }; + pane.cur_pinned = true; + return pane; + } + + /// Mark same-page pixels stale while retaining the one visible RGBA and + /// its dimensions. drawPdf swaps in a replacement only after MuPDF and the + /// allocation both succeed, so a failed highlight rerender never blanks a + /// page which was already visible. + fn invalidatePdfRaster(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + _ = p; + pv.render_tried = false; + } + + fn dropPdfRaster(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (pv.rgba.len > 0) p.gpa.free(pv.rgba); + pv.rgba = &.{}; + pv.render_tried = false; + } + + fn dropPdfSearchResults(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (pv.search_results) |*results| results.deinit(p.gpa); + pv.search_results = null; + } + + fn dropPdfSelection(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (pv.selection) |*selection| selection.deinit(p.gpa); + pv.selection = null; + if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text); + pv.selection_text = &.{}; + pv.selection_anchor = null; + pv.selection_head = null; + } + + fn clearPdfDrag(pv: *PdfView) void { + if (comptime !pdf_enabled) return; + pv.drag_anchor = null; + pv.drag_head = null; + } + + fn clearPdfSelection(p: *Pardes, pane: *Pane) void { + if (comptime !pdf_enabled) return; + const pv = &(pane.pdf orelse return); + const changed = pv.selection != null or pv.selection_text.len > 0; + p.dropPdfSelection(pv); + if (changed) p.invalidatePdfRaster(pv); + } + + /// Drop everything derived from one PDF page. The document and full search + /// query are semantic document state and survive; page search geometry, + /// selection, pixels, projected text and pans do not. + fn invalidatePdfPage(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + p.dropPdfRaster(pv); + p.dropPdfSearchResults(pv); + p.dropPdfSelection(pv); + clearPdfDrag(pv); + if (pv.text.len > 0) p.gpa.free(pv.text); + pv.iw = 0; + pv.ih = 0; + pv.text = &.{}; + pv.text_tried = false; + pv.text_scroll = 0; + pv.pan_x = 0; + pv.pan_y = 0; + pv.highlights_dirty = pv.search_query.len > 0; + pv.search_reveal_pending = pv.search_query.len > 0; + pv.search_hit = 0; + } + + /// Replace the full PDF query and discard only query-derived page state. + /// Resolution is lazy in drawPdf; fit and pan remain placement-only state. + fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) void { + if (comptime !pdf_enabled) return; + if (std.mem.eql(u8, pv.search_query, query)) return; + const owned = p.gpa.dupe(u8, query) catch return; + if (pv.search_query.len > 0) p.gpa.free(pv.search_query); + pv.search_query = owned; + pv.search_hit = 0; + p.dropPdfSearchResults(pv); + pv.highlights_dirty = query.len > 0; + pv.search_reveal_pending = query.len > 0; + p.invalidatePdfRaster(pv); + } + + fn resolvePdfSearch(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (!pv.highlights_dirty) return; + pv.highlights_dirty = false; + p.dropPdfSearchResults(pv); + if (pv.search_query.len == 0) return; + const results = pv.document.search(p.gpa, pv.page, pv.search_query) catch return; + pv.search_hit = if (results.hit_count == 0) + 0 + else + @min(pv.search_hit, results.hit_count - 1); + pv.search_results = results; + } + + fn normalizedPdfPixel(value: f32, dimension: usize) u32 { + const scaled = std.math.clamp(value, 0, 1) * + @as(f32, @floatFromInt(dimension)); + return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled)))); + } + + fn rearmPdfRevealForViewport(p: *Pardes, pane: *Pane, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (pv.search_query.len == 0) return; + const viewport = p.pdfViewport(pane) orelse return; + if (!pv.reveal_viewport_valid or + pv.reveal_viewport_w != viewport.pixel_w or + pv.reveal_viewport_h != viewport.pixel_h or + pv.reveal_fit != pv.fit) + pv.search_reveal_pending = true; + } + + fn revealPdfSearch(p: *Pardes, pane: *Pane, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (!pv.search_reveal_pending) return; + const viewport = p.pdfViewport(pane) orelse return; + pv.reveal_viewport_w = viewport.pixel_w; + pv.reveal_viewport_h = viewport.pixel_h; + pv.reveal_fit = pv.fit; + pv.reveal_viewport_valid = true; + const results = pv.search_results orelse { + pv.search_reveal_pending = false; + return; + }; + if (results.hit_count == 0 or results.quads.len == 0) { + pv.search_reveal_pending = false; + return; + } + pv.search_hit = @min(pv.search_hit, results.hit_count - 1); + const q = for (results.quads) |item| { + if (item.hit == pv.search_hit) break item.quad; + } else { + pv.search_reveal_pending = false; + return; + }; + const geometry = p.pdfGeometry(pane) orelse return; + pv.search_reveal_pending = false; + const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4; + const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4; + const full: u32 = @intCast(switch (pv.fit) { + .width => pv.ih, + .height => pv.iw, + }); + const at = switch (pv.fit) { + .width => normalizedPdfPixel(center_y, pv.ih), + .height => normalizedPdfPixel(center_x, pv.iw), + }; + const crop_at = switch (pv.fit) { + .width => geometry.src.y, + .height => geometry.src.x, + }; + const crop = switch (pv.fit) { + .width => geometry.src.h, + .height => geometry.src.w, + }; + if (at >= crop_at and at < crop_at + crop) return; + const overflow = full -| crop; + if (overflow == 0) return; + const wanted = @min(overflow, at -| crop / 2); + const pan: u16 = @intCast( + (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, + ); + switch (pv.fit) { + .width => pv.pan_y = pan, + .height => pv.pan_x = pan, + } + } + + /// Change the visible PDF page and invalidate only page-derived caches. + /// The document/context survives, so navigation never reparses the file. + fn setPdfPage(p: *Pardes, pane: *Pane, page: usize) void { + if (comptime !pdf_enabled) return; + const pv = &(pane.pdf orelse return); + const next = @min(page, pv.page_count -| 1); + if (next == pv.page) return; + p.invalidatePdfPage(pv); + pv.page = next; + pane.cur_row = 0; + pane.cur_col = 0; + pane.vsel.active = false; + pane.msel.active = false; + pane.nsel = 0; + } + + /// Builtin entry point. Public only because builtins.zig is the command + /// registry; the PdfView representation remains private to this module. + pub fn togglePdfFit(p: *Pardes, pane: *Pane) void { + if (comptime !pdf_enabled) return; + _ = p; + const pv = &(pane.pdf orelse return); + pv.fit = if (pv.fit == .width) .height else .width; + pv.pan_x = 0; + pv.pan_y = 0; + pv.search_reveal_pending = pv.search_query.len > 0; + } + + fn stepPdfPage(p: *Pardes, pane: *Pane, delta: i64) void { + if (comptime !pdf_enabled) return; + const pv = pane.pdf orelse return; + const current: i64 = @intCast(pv.page); + const last: i64 = @intCast(pv.page_count -| 1); + p.setPdfPage(pane, @intCast(std.math.clamp(current + delta, 0, last))); + } + + const PdfViewport = struct { + pixel_w: u32, + pixel_h: u32, + }; + + const PdfPanAxis = enum { horizontal, vertical }; + const PdfPanResult = enum { moved, edge, unavailable }; + + /// The image shells receive a cell rectangle while NativeGeometry works + /// in pixels. Keep this conversion in core state so navigation and drawing + /// agree even when the font metrics change at runtime. + fn pdfViewport(p: *const Pardes, pane: *const Pane) ?PdfViewport { + const r = for (p.panes, 0..) |slot, i| { + if (slot == pane) break p.rects[i]; + } else return null; + const cols = r.w -| config.GUTTER; + const rows = r.h -| BOX_H; + if (cols == 0 or rows == 0) return null; + return .{ + .pixel_w = @as(u32, cols) * @as(u32, p.cell_pixels.w), + .pixel_h = @as(u32, rows) * @as(u32, p.cell_pixels.h), + }; + } + + fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry { + if (comptime !pdf_enabled) return null; + const pv = pane.pdf orelse return null; + const viewport = p.pdfViewport(pane) orelse return null; + return image.nativeGeometry( + pv.iw, + pv.ih, + viewport.pixel_w, + viewport.pixel_h, + switch (pv.fit) { + .width => .width, + .height => .height, + }, + pv.pan_x, + pv.pan_y, + ); + } + + fn pdfNativeReady(p: *const Pardes, pane: *const Pane) bool { + return p.native_images and p.pdfGeometry(pane) != null; + } + + /// Convert a grid mouse position to normalized page space through the + /// exact crop/destination transform used by both native backends. Presses + /// in a letterbox return null; a live drag clamps to the page edge so a + /// sweep can finish naturally outside the image. + fn pdfPointAt( + p: *const Pardes, + pane: *const Pane, + mcol: u16, + mrow: u16, + clamp_to_page: bool, + ) ?PdfPoint { + if (comptime !pdf_enabled) return null; + const pv = pane.pdf orelse return null; + const geometry = p.pdfGeometry(pane) orelse return null; + const r = for (p.panes, 0..) |slot, i| { + if (slot == pane) break p.rects[i]; + } else return null; + const body_x = @as(i64, r.x + config.GUTTER); + const body_y = @as(i64, r.y + BOX_H); + var px = (@as(i64, mcol) - body_x) * p.cell_pixels.w + p.cell_pixels.w / 2; + var py = (@as(i64, mrow) - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2; + if (clamp_to_page) { + px = std.math.clamp( + px, + @as(i64, geometry.dst.x), + @as(i64, geometry.dst.x + geometry.dst.w - 1), + ); + py = std.math.clamp( + py, + @as(i64, geometry.dst.y), + @as(i64, geometry.dst.y + geometry.dst.h - 1), + ); + } else if (px < 0 or py < 0 or + px > std.math.maxInt(u32) or py > std.math.maxInt(u32)) return null; + const source = geometry.sourcePoint(@intCast(px), @intCast(py)) orelse return null; + const fw: f32 = @floatFromInt(pv.iw); + const fh: f32 = @floatFromInt(pv.ih); + return .{ + .x = (@as(f32, @floatFromInt(source.x)) + 0.5) / fw, + .y = (@as(f32, @floatFromInt(source.y)) + 0.5) / fh, + }; + } + + const PdfSelectionUpdate = enum { failed, unchanged, changed }; + + /// Transactionally replace the word-snapped selection and its owned text. + /// Drags defer the expensive baked-highlight raster until release; + /// snap-word execute/look presses may still request an immediate update. + fn setPdfSelection( + p: *Pardes, + pane: *Pane, + start: PdfPoint, + end: PdfPoint, + invalidate_raster: bool, + ) PdfSelectionUpdate { + if (comptime !pdf_enabled) return .failed; + const pv = &(pane.pdf orelse return .failed); + if (pv.selection != null and + pv.selection_anchor != null and pv.selection_head != null and + pv.selection_anchor.?.x == start.x and pv.selection_anchor.?.y == start.y and + pv.selection_head.?.x == end.x and pv.selection_head.?.y == end.y) return .unchanged; + var selection = pv.document.select(p.gpa, pv.page, start, end) catch return .failed; + const text = pv.document.copySelection( + p.gpa, + pv.page, + selection.start, + selection.end, + ) catch { + selection.deinit(p.gpa); + return .failed; + }; + + p.dropPdfSelection(pv); + pv.selection = selection; + pv.selection_text = text; + pv.selection_anchor = start; + pv.selection_head = end; + if (invalidate_raster) p.invalidatePdfRaster(pv); + return .changed; + } + + fn beginPdfDrag( + p: *Pardes, + pane: *Pane, + mcol: u16, + mrow: u16, + snap_word: bool, + ) bool { + if (comptime !pdf_enabled) return false; + const pv = &(pane.pdf orelse return false); + clearPdfDrag(pv); + const point = p.pdfPointAt(pane, mcol, mrow, false) orelse { + return false; + }; + pv.drag_anchor = point; + pv.drag_head = point; + if (!snap_word) return true; + if (pv.selection) |selection| if (selection.contains(point)) { + return true; + }; + if (p.setPdfSelection(pane, point, point, true) == .failed) { + // The old selection is intentionally transactional and survives, + // but this outside click must not execute that stale text. + clearPdfDrag(pv); + return false; + } + return true; + } + + fn beginPdfSelection(p: *Pardes, pane: *Pane, mcol: u16, mrow: u16) bool { + return p.beginPdfDrag(pane, mcol, mrow, true); + } + + fn updatePdfSelection( + p: *Pardes, + pane: *Pane, + mcol: u16, + mrow: u16, + invalidate_raster: bool, + ) bool { + if (comptime !pdf_enabled) return false; + const pv = &(pane.pdf orelse return false); + const anchor = pv.drag_anchor orelse return false; + const point = p.pdfPointAt(pane, mcol, mrow, true) orelse return false; + if (pv.drag_head) |head| if (head.x == point.x and head.y == point.y) return false; + const result = p.setPdfSelection(pane, anchor, point, invalidate_raster); + if (result != .failed) { + pv.drag_head = point; + } + return result == .changed; + } + + fn pdfHighlights(p: *Pardes, pv: *PdfView) ![]const PdfHighlight { + if (comptime !pdf_enabled) return &.{}; + var highlights: std.ArrayList(PdfHighlight) = .empty; + const arena = p.scratch.allocator(); + if (pv.search_results) |results| { + try highlights.ensureUnusedCapacity(arena, results.quads.len); + for (results.quads) |item| highlights.appendAssumeCapacity(pdf_impl.Highlight.init( + item.quad, + .{ 0xff, 0xd5, 0x4f, 0x70 }, + .search, + )); + } + if (pv.selection) |selection| { + try highlights.ensureUnusedCapacity(arena, selection.quads.len); + for (selection.quads) |quad| highlights.appendAssumeCapacity(pdf_impl.Highlight.init( + quad, + .{ 0x55, 0xa7, 0xff, 0x78 }, + .selection, + )); + } + return highlights.items; + } + + /// Move one normalized pan axis by a distance measured in DISPLAY pixels. + /// NativeGeometry supplies both the displayed and cropped extents, so this + /// remains exact for non-square cells and rounded page scaling. + fn panPdfPixels( + p: *Pardes, + pane: *Pane, + axis: PdfPanAxis, + direction: i8, + display_pixels: u32, + ) PdfPanResult { + if (comptime !pdf_enabled) return .unavailable; + const geometry = p.pdfGeometry(pane) orelse return .unavailable; + const pv = &(pane.pdf orelse return .unavailable); + const source_full: u32 = @intCast(switch (axis) { + .horizontal => pv.iw, + .vertical => pv.ih, + }); + const crop = switch (axis) { + .horizontal => geometry.src.w, + .vertical => geometry.src.h, + }; + const source_at = switch (axis) { + .horizontal => geometry.src.x, + .vertical => geometry.src.y, + }; + const displayed = @max(@as(u32, 1), switch (axis) { + .horizontal => geometry.dst.w, + .vertical => geometry.dst.h, + }); + const overflow = source_full -| crop; + if (overflow == 0 or + (direction < 0 and source_at == 0) or + (direction > 0 and source_at >= overflow)) return .edge; + + const source_step = @max( + @as(u64, 1), + (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed, + ); + const normalized_step: u32 = @intCast(@min( + @as(u64, std.math.maxInt(u16)), + @max( + @as(u64, 1), + (source_step * std.math.maxInt(u16) + overflow - 1) / overflow, + ), + )); + const position = switch (axis) { + .horizontal => &pv.pan_x, + .vertical => &pv.pan_y, + }; + if (direction > 0) { + position.* = @intCast(@min( + @as(u32, std.math.maxInt(u16)), + @as(u32, position.*) + normalized_step, + )); + } else { + position.* -|= @intCast(normalized_step); + } + return .moved; + } + + fn scaledPdfStep(base: u32, count: u32) u32 { + return @intCast(@min( + @as(u64, std.math.maxInt(u32)), + @as(u64, base) * @max(@as(u64, 1), count), + )); + } + + /// Fit-width reads down a tall raster, then crosses pages at its extrema. + /// Going backward deliberately enters the previous page at the bottom. + fn panPdfWidth(p: *Pardes, pane: *Pane, direction: i8, pixels: u32) void { + switch (p.panPdfPixels(pane, .vertical, direction, pixels)) { + .moved => {}, + // Before the first native frame (or after a render failure), do + // not swallow navigation: page-wise is the useful fallback. + .unavailable => p.stepPdfPage(pane, direction), + .edge => { + const pv = pane.pdf orelse return; + if (direction > 0) { + if (pv.page + 1 >= pv.page_count) return; + p.setPdfPage(pane, pv.page + 1); + } else { + if (pv.page == 0) return; + p.setPdfPage(pane, pv.page - 1); + pane.pdf.?.pan_y = std.math.maxInt(u16); + } + }, + } + } + + fn pdfVerticalWheel(p: *Pardes, pane: *Pane, direction: i8) void { + if (comptime !pdf_enabled) return; + const pv = pane.pdf orelse return; + if (!p.native_images or pv.fit == .height) { + p.stepPdfPage(pane, direction); + return; + } + const rows: u32 = @intCast(@max(1, config.wheel_rows)); + p.panPdfWidth(pane, direction, scaledPdfStep(p.cell_pixels.h, rows)); + } + + fn pdfHorizontalWheel(p: *Pardes, pane: *Pane, direction: i8) void { + if (comptime !pdf_enabled) return; + const pv = pane.pdf orelse return; + if (!p.native_images or pv.fit != .height) return; + const cols: u32 = @intCast(@max(1, config.wheel_cols)); + _ = p.panPdfPixels(pane, .horizontal, direction, scaledPdfStep(p.cell_pixels.w, cols)); + } + + /// In fit-height, vertical motions retain the old page-at-a-time model. + /// In fit-width they pan by a row, half viewport or full viewport before + /// crossing pages. Search, tags and global chords run before this hook. + fn pdfNavigationKey(p: *Pardes, pane: *Pane, key: Key) bool { + if (comptime !pdf_enabled) return false; + if (pane.pdf == null or pane.pending != 0) return false; + const row_down = hit(key, config.move_down); + const row_up = hit(key, config.move_up); + const half_down = hit(key, config.half_page_down); + const half_up = hit(key, config.half_page_up); + const page_down = hit(key, config.page_down); + const page_up = hit(key, config.page_up); + const down = row_down or half_down or page_down; + const up = row_up or half_up or page_up; + if (!down and !up) return false; + const count = @max(1, pane.count); + pane.count = 0; + const direction: i8 = if (down) 1 else -1; + if (!p.native_images or pane.pdf.?.fit == .height) { + const pages: i64 = @intCast(count); + p.stepPdfPage(pane, if (down) pages else -pages); + return true; + } + const viewport = p.pdfViewport(pane); + const base = if (row_down or row_up) + @as(u32, p.cell_pixels.h) + else if (half_down or half_up) + if (viewport) |v| @max(@as(u32, 1), v.pixel_h / 2) else @as(u32, p.cell_pixels.h) + else if (viewport) |v| + v.pixel_h + else + @as(u32, p.cell_pixels.h); + p.panPdfWidth(pane, direction, scaledPdfStep(base, count)); + return true; + } + /// stack a fresh doc pane at the top of the LEFT column (acme convention: /// files left, terminals right), halving ONLY the old top pane's slot so /// the rest of the column keeps its sizes; then evict a leftover pristine @@ -6296,6 +7846,20 @@ pub const Pardes = struct { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; + if (hasPdf(pane)) { + if (comptime pdf_enabled) { + if (at.line > 0) p.setPdfPage(pane, at.line - 1); + const pv = &pane.pdf.?; + // PDF search result rows use Spot.col as a one-based MuPDF + // logical hit id, not a text column. Rearming reveal moves + // only fit/pan; the page raster already contains every hit. + if (at.col > 0 and pv.search_query.len > 0) { + pv.search_hit = at.col - 1; + pv.search_reveal_pending = true; + } + } + return; + } if (at.line == 0) return; if (pane.file) |*f| { if (at.line > file_pane.nlines(p.gpa, f)) return; @@ -6352,6 +7916,10 @@ pub const Pardes = struct { p.active = i; return true; }; + if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { + p.focusPaneLine(i, at); + return true; + }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; p.focusPaneLine(i, at); @@ -6406,7 +7974,7 @@ pub const Pardes = struct { const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane; if (n > 0 and k == id) continue; // tried first, skip the rerun const op = p.panes[k] orelse continue; - const dir = if (op.file) |f| (std.fs.path.dirname(f.path) orelse "/") else op.cwdSlice(); + const dir = paneDir(op); for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand; seen[nseen] = dir; nseen += 1; @@ -6472,6 +8040,13 @@ pub const Pardes = struct { p.active = free; }, .file => |target| { + if (comptime pdf_enabled) if (target.kind == .pdf) { + if (p.focusPaneByPath(target.path, target.at)) return; + const free = p.freeSlot() orelse return; + const nt = p.openPdfView(free, target.path, target.at.line) catch return; + p.placeDoc(id, free, nt); + return; + }; // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return; @@ -6561,6 +8136,8 @@ pub const Pardes = struct { // directory (found by cwd, else freshly forked). const dst = (if (pane.file) |f| p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") + else if (hasPdf(pane)) + p.ttyForDir(paneDir(pane)) else id) orelse return null; term_pane.padOutputBelowEdits(p, dst); @@ -6602,7 +8179,7 @@ pub const Pardes = struct { pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null) { + if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null or hasPdf(src)) { src_id = from_id; }; // Opened from somewhere that is NOT a doc (a shell, a results list): @@ -6626,7 +8203,7 @@ pub const Pardes = struct { const j = p.jumps[n - 1]; const pp = p.panes[j.pane] orelse continue; if (pp.serial != j.serial) continue; - if (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null) { + if (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp)) { src_id = j.pane; break; } @@ -6637,7 +8214,7 @@ pub const Pardes = struct { // doc never claims a second column outranks knowing where you were, so // the old slot-order scan stays as the fallback. if (doc and src_id == null) for (p.panes, 0..) |sl, i| { - if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null)) { + if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp))) { src_id = i; break; }; @@ -6698,6 +8275,27 @@ pub const Pardes = struct { .origin = if (f.output) |o| output_pane.word(o.from) else "", .origin_arg = if (f.output) |*o| o.arg() else "", }, + } else if (hasPdf(pane)) blk: { + if (comptime !pdf_enabled) unreachable; + const pv = pane.pdf.?; + const bytes = look.readFile(arena, pv.path) catch ""; + // Keep the dump schema backwards-compatible: a raster-backed + // document rides the existing binary image record, while its + // `scroll` field is the zero-based PDF page. Restore inspects + // the extension and reconstructs the semantic PDF pane. + break :blk .{ + .kind = .image, + .tag = tag, + .body = body, + .scroll = pv.page, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .image = .{ + .path = pv.path, + .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", + }, + }; } else if (pane.image) |iv| blk: { const bytes = look.readFile(arena, iv.path) catch ""; break :blk .{ @@ -6864,15 +8462,36 @@ pub const Pardes = struct { if (out == null) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); p.restoreTail(pane, src.tag); }, - .image => { + .image => restore_image: { const im = src.image.?; - const path = try gpa.dupe(u8, im.path); - errdefer gpa.free(path); - const pane = try p.newDocPane(i); const raw: []u8 = if (im.bytes_b64.len > 0) dump.decodeBytes(gpa, im.bytes_b64) catch &.{} else &.{}; + if (comptime pdf_enabled) if (look.isPdfPath(im.path)) { + if (p.openPdfView(i, im.path, src.scroll + 1) catch null) |pane| { + if (raw.len > 0) gpa.free(raw); + pane.cols = @max(1, src.cols); + pane.rows = @max(1, src.rows); + break :restore_image; + } + // If the source path is unavailable, retain its bytes + // as an ordinary file rather than dropping the pane. + const path = try gpa.dupe(u8, im.path); + errdefer gpa.free(path); + const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); + errdefer if (content.len > 0) gpa.free(content); + const pane = try p.newDocPane(i); + pane.file = .{ .path = path, .content = content }; + pane.cur_pinned = true; + pane.cols = @max(1, src.cols); + pane.rows = @max(1, src.rows); + p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); + break :restore_image; + }; + const path = try gpa.dupe(u8, im.path); + errdefer gpa.free(path); + const pane = try p.newDocPane(i); pane.image = .{ .path = path, .raw = raw }; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); @@ -6975,8 +8594,8 @@ pub const Pardes = struct { const now: Loc = .{ .pane = @intCast(p.active), .serial = pane.serial, - .line = if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0, - .col = if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0, + .line = if (pane.pdfPage()) |page| @intCast(page + 1) else if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0, + .col = if (pane.pdfPage() != null) 0 else if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0, }; if (p.njumps > 0) { const at = &p.jumps[p.jcur]; @@ -7206,6 +8825,9 @@ pub const Pardes = struct { if (at.file) |*f| { sb_off = f.scroll; sb_total = file_pane.nlines(p.gpa, f); + } else if (at.pdfPage()) |page| { + sb_off = page; + sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { const sb = at.vt.screens.active.pages.scrollbar(); sb_off = sb.offset; @@ -7345,6 +8967,15 @@ pub const Pardes = struct { if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .y = r.y, .bar = pane.mode == .insert }; } + // A native PDF page uses the same backend-neutral pixel attachment as + // an image. Without native pixels it deliberately falls through: its + // extracted text projection becomes an ordinary readable body. + if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) { + s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); + s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg }); + return; + } + // image pane: the picture fills the body — petscii glyph art into the // cells, or a pixel attachment the shell places (kitty). Plain // thumbless gutter so it reads like any other pane. @@ -7527,6 +9158,10 @@ pub const Pardes = struct { .total = file_pane.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, + } else if (pane.pdfPage()) |page| .{ + .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1, + .offset = page, + .len = 1, } else blk: { const gsb = pane.vt.screens.active.pages.scrollbar(); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; @@ -7542,6 +9177,62 @@ pub const Pardes = struct { } } + /// Render the current PDF page once and attach its stable RGBA until page + /// navigation invalidates it. MuPDF rendering is bounded to a 1600-pixel + /// longest side in pdf.zig, independent of malicious page dimensions. + fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool { + if (comptime !pdf_enabled) return false; + const pv = &(pane.pdf orelse return false); + if (!pv.render_tried) { + pv.render_tried = true; + p.resolvePdfSearch(pv); + const rendered = render: { + const highlights = p.pdfHighlights(pv) catch break :render null; + const fresh = if (highlights.len > 0) + pv.document.renderWithHighlights(p.gpa, pv.page, highlights) catch break :render null + else + pv.document.render(p.gpa, pv.page) catch break :render null; + break :render fresh; + }; + if (rendered) |fresh| { + if (pv.rgba.len > 0) p.gpa.free(pv.rgba); + pv.rgba = fresh.rgba; + pv.iw = fresh.width; + pv.ih = fresh.height; + pv.render_revision +%= 1; + if (pv.render_revision == 0) pv.render_revision = 1; + } + } + p.rearmPdfRevealForViewport(pane, pv); + p.revealPdfSearch(pane, pv); + if (pv.rgba.len == 0 or r.h <= BOX_H) return false; + const id: u8 = @intCast(for (p.panes, 0..) |slot, i| { + if (slot == pane) break i; + } else 0); + p.surface.images[p.surface.nimages] = .{ + .pane = id, + .serial = pane.serial, + .native = .{ + .revision = pv.render_revision, + .fit = switch (pv.fit) { + .width => .width, + .height => .height, + }, + .pan_x = pv.pan_x, + .pan_y = pv.pan_y, + }, + .x = tx, + .y = r.y + BOX_H, + .w = tw, + .h = r.h - BOX_H, + .rgba = pv.rgba, + .iw = pv.iw, + .ih = pv.ih, + }; + p.surface.nimages += 1; + return true; + } + /// Image pane body: decode once (path via look, or dump-loaded raw bytes), /// rebuild the petscii grid when the size/toggles changed, then either /// blit the glyph art into cells or attach the pixels for the shell. @@ -7627,12 +9318,27 @@ pub const Pardes = struct { /// rows it covers — which is where a buffer holding more lines than those /// rows pushes the output below it down the screen. fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { - _ = p; if (pane.image != null) { const buf = try arena.alloc(u8, pane.rows -| 1); @memset(buf, '\n'); return buf; } + if (hasPdf(pane)) { + const text = try p.ensurePdfText(pane); + const scroll = if (comptime pdf_enabled) pane.pdf.?.text_scroll else 0; + var out: std.ArrayList(u8) = .empty; + var lines = std.mem.splitScalar(u8, text, '\n'); + var row: usize = 0; + var wrote: usize = 0; + while (lines.next()) |line| : (row += 1) { + if (row < scroll) continue; + if (wrote >= pane.rows -| BOX_H) break; + if (wrote > 0) try out.append(arena, '\n'); + try out.appendSlice(arena, line); + wrote += 1; + } + return out.items; + } if (pane.file) |*f| return file_pane.bodyText(arena, pane, f); return term_pane.bodyText(arena, pane); } |
