summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig1786
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);
}