summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-03 09:35:25 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commitcd4a5b991104a2dc96a4bb7390451fb384376a9a (patch)
treef59f04aa83266d9ddfbabcb18bad8c3277e74bd7 /src/pardes.zig
parenta69cd4a7ef5c4527e3a7cd325d49b6bf445bd810 (diff)
downloadpardes-cd4a5b991104a2dc96a4bb7390451fb384376a9a.tar.gz
pardes-cd4a5b991104a2dc96a4bb7390451fb384376a9a.zip
add PDF sections output and Look navigation
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig538
1 files changed, 537 insertions, 1 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 70b23a86..7243cf37 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1219,18 +1219,236 @@ const Builtin = builtins.Builtin();
test "PDF builtins and leader paths follow the MuPDF feature gate" {
const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint");
+ const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections");
try std.testing.expectEqual(pdf_enabled, maybe_fit != null);
try std.testing.expectEqual(pdf_enabled, maybe_tint != null);
+ try std.testing.expectEqual(pdf_enabled, maybe_sections != null);
if (pdf_enabled) {
const fit_path = config.leader_path.get(maybe_fit.?) orelse
return error.MissingPdfFitLeaderPath;
const tint_path = config.leader_path.get(maybe_tint.?) orelse
return error.MissingPdfTintLeaderPath;
+ const sections_path = config.leader_path.get(maybe_sections.?) orelse
+ return error.MissingPdfSectionsLeaderPath;
try std.testing.expectEqualStrings("tz", fit_path);
try std.testing.expectEqualStrings("ti", tint_path);
+ try std.testing.expectEqualStrings("ts", sections_path);
+ const traits = output_pane.traits(.{ .cmd = maybe_sections.? });
+ try std.testing.expectEqualStrings(config.pdf_sections_buffer, traits.name);
+ try std.testing.expect(traits.steps);
+ try std.testing.expect(!traits.executes);
+ const help_row = for (builtin_rows) |row| {
+ if (row.cmd == maybe_sections.?) break row;
+ } else return error.MissingPdfSectionsHelpRow;
+ try std.testing.expectEqualStrings("ts", help_row.path.?);
+ try std.testing.expect(std.mem.indexOf(u8, help_row.line, "PdfSections") != null);
}
}
+test "PDF section rows preserve DFS ordinals and sanitise hierarchy" {
+ if (!pdf_enabled) return;
+ const E = pdf_impl.OutlineEntry;
+ const entries = [_]E{
+ .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } },
+ .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } },
+ .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } },
+ .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } },
+ .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } },
+ };
+ const rows = try PdfSectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries);
+ defer std.testing.allocator.free(rows);
+ try std.testing.expectEqualStrings(
+ "/tmp/manual.pdf:3:1 [untitled]\n" ++
+ "/tmp/manual.pdf:3:2 Child Title Café Next\n" ++
+ "/tmp/manual.pdf:1:3 [empty title]\n" ++
+ "https://example.com/manual External\n" ++
+ "https://example.com/deep Linked branch\n" ++
+ "https://example.com/deep Deep link\n",
+ rows,
+ );
+ try std.testing.expect(PdfSectionRows.resolve(&entries, 4) == null);
+ try std.testing.expect(PdfSectionRows.resolve(&entries, 5) == null);
+}
+
+test "PdfSections output, Look, and n/N share exact cached outline destinations" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
+
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pdf_pane = p.panes[0].?;
+ const sections = std.meta.stringToEnum(Builtin, "PdfSections") orelse
+ return error.MissingPdfSectionsBuiltin;
+
+ // Pane-scoped: a shell invocation is inert; the PDF invocation opens one
+ // output immediately below it and leaves focus/step ownership on the PDF.
+ p.runBuiltin(sections, 1, "", null);
+ try std.testing.expect(pdf_pane.search_pane == null);
+ p.runBuiltin(sections, 0, "", null);
+ const output_id = pdf_pane.search_pane orelse return error.MissingPdfSectionsOutput;
+ const output = p.panes[output_id].?;
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ const pdf_layout = p.layoutFindTerm(0) orelse return error.MissingPdfLayout;
+ const output_layout = p.layoutFindTerm(output_id) orelse return error.MissingPdfSectionsLayout;
+ try std.testing.expectEqual(pdf_layout.col, output_layout.col);
+ try std.testing.expectEqual(pdf_layout.idx + 1, output_layout.idx);
+ try std.testing.expect(Pardes.isPdfSectionsOutput(output));
+ try std.testing.expect(std.mem.endsWith(u8, output.file.?.path, "/+PdfSections"));
+ const tag = try p.tagText(p.scratch.allocator(), pdf_pane);
+ try std.testing.expect(std.mem.indexOf(u8, tag, "PdfSections") != null);
+
+ const expected = try std.fmt.allocPrint(
+ gpa,
+ "{s}:2:1 [untitled]\n" ++
+ "{s}:2:2 Café 子\n" ++
+ "{s}:3:3 [empty title]\n" ++
+ "https://example.com/manual External\n",
+ .{ path, path, path },
+ );
+ defer gpa.free(expected);
+ try std.testing.expectEqualStrings(expected, output.file.?.content);
+
+ // The outline is loaded once and a clean live output is reused in place.
+ try std.testing.expect(pdf_pane.pdf.?.outline_tried);
+ const outline_ptr = pdf_pane.pdf.?.outline.?.entries.ptr;
+ const output_serial = output.serial;
+ const output_revision = output.file.?.revision;
+ p.runBuiltin(sections, 0, "", null);
+ try std.testing.expectEqual(output_id, pdf_pane.search_pane.?);
+ try std.testing.expectEqual(output_serial, p.panes[output_id].?.serial);
+ try std.testing.expectEqual(output_revision, p.panes[output_id].?.file.?.revision);
+ try std.testing.expectEqual(outline_ptr, pdf_pane.pdf.?.outline.?.entries.ptr);
+
+ // n and N execute the generated row through ordinary Look. The root and
+ // child intentionally resolve to the same exact XYZ destination.
+ p.active = 0;
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(?usize, 0), pdf_pane.search_row);
+ try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.page);
+ const child_destination = pdf_pane.pdf.?.outline.?.entries[1].destination.internal;
+ const expected_y = @as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])) +
+ @as(f64, child_destination.y.?) / @as(f64, pdf_pane.pdf.?.page_sizes[1].height) *
+ @as(f64, @floatFromInt(pdf_pane.pdf.?.page_heights[1]));
+ try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(?usize, 1), pdf_pane.search_row);
+ try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
+ p.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(usize, 2), pdf_pane.pdf.?.page);
+ p.update(.{ .key = .{ .cp = 'N' } });
+ try std.testing.expectEqual(@as(?usize, 1), pdf_pane.search_row);
+ try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.page);
+ try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
+
+ // Re-looking the same ordinal changes placement only, never pixel identity.
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const revision = pdf_pane.pdf.?.render_revision;
+ const child_row = modal.lineSlice(output.file.?.content, 1);
+ const child_target = child_row[0 .. std.mem.indexOfScalar(u8, child_row, ' ') orelse child_row.len];
+ p.lookAt(output_id, child_target);
+ try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
+
+ // The same path:page:col token from any non-PdfSections pane retains the
+ // established PDF-search meaning for col; no outline reveal is installed.
+ p.setPdfSearchQuery(&pdf_pane.pdf.?, "ordinary search semantics");
+ p.lookAt(1, child_target);
+ try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.search_hit);
+ try std.testing.expect(pdf_pane.pdf.?.outline_reveal_pending == null);
+ p.setPdfSearchQuery(&pdf_pane.pdf.?, "");
+
+ // Step forward onto the external row: it uses the existing open_link
+ // effect and searchStep restores focus to the owning PDF.
+ p.active = 0;
+ p.update(.{ .key = .{ .cp = 'n' } });
+ p.update(.{ .key = .{ .cp = 'n' } });
+ var opened = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .open_link => |uri| opened = std.mem.eql(u8, uri.slice(), "https://example.com/manual"),
+ else => {},
+ };
+ try std.testing.expect(opened);
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+
+ // Missing/hostile coordinates clamp to page space. x affects only a
+ // layout with horizontal overflow and still does not touch raster state.
+ const saved_size = pdf_pane.pdf.?.page_sizes[1];
+ pdf_pane.pdf.?.fit = .width;
+ pdf_pane.pdf.?.layout_valid = false;
+ pdf_pane.pdf.?.pan_x = 123;
+ p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = 190, .y = null });
+ try std.testing.expectEqual(@as(u16, 123), pdf_pane.pdf.?.pan_x);
+ try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
+ pdf_pane.pdf.?.page_sizes[1] = .{ .width = 400, .height = 100 };
+ pdf_pane.pdf.?.fit = .height;
+ pdf_pane.pdf.?.layout_valid = false;
+ pdf_pane.pdf.?.pan_x = 0;
+ p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = 390, .y = -500 });
+ try std.testing.expect(pdf_pane.pdf.?.pan_x > 0);
+ try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
+ p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = std.math.inf(f32), .y = std.math.inf(f32) });
+ try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
+ try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
+ pdf_pane.pdf.?.page_sizes[1] = saved_size;
+
+ // An edited output is preserved. Closing the replacement and reusing its
+ // slot for an unrelated pane cannot trick the remembered id into a refill.
+ file_pane.setContent(p, &output.file.?, try gpa.dupe(u8, "edited by user\n"));
+ p.openPdfSections(0);
+ const replacement_id = pdf_pane.search_pane orelse return error.MissingReplacementSections;
+ try std.testing.expect(replacement_id != output_id);
+ try std.testing.expectEqualStrings("edited by user\n", output.file.?.content);
+ const replacement_serial = p.panes[replacement_id].?.serial;
+ p.runBuiltin(.Del, replacement_id, "", null);
+ try std.testing.expect(p.panes[replacement_id] == null);
+ const unrelated = try p.openTutorView(replacement_id);
+ p.placeDoc(0, replacement_id, unrelated);
+ try std.testing.expect(unrelated.serial != replacement_serial);
+ p.openPdfSections(0);
+ try std.testing.expect(pdf_pane.search_pane.? != replacement_id);
+ try std.testing.expect(Pardes.isPdfSectionsOutput(p.panes[pdf_pane.search_pane.?].?));
+}
+
+test "PdfSections opens an empty cached output for a PDF without an outline" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path});
+ const p = try Pardes.init(gpa, .{ .file = path });
+ defer p.deinit();
+ p.openPdfSections(0);
+ const pane = p.panes[0].?;
+ const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections;
+ try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len);
+ try std.testing.expect(pane.pdf.?.outline_tried);
+ try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.outline.?.entries.len);
+ const serial = p.panes[output_id].?.serial;
+ p.openPdfSections(0);
+ try std.testing.expectEqual(output_id, pane.search_pane.?);
+ try std.testing.expectEqual(serial, p.panes[output_id].?.serial);
+}
+
fn nextPipeEffect(p: *Pardes) ?u32 {
while (p.nextEffect()) |effect| switch (effect) {
.pipe => |request| return request.id,
@@ -2797,6 +3015,14 @@ const PdfRaster = if (pdf_enabled) struct {
revision: u32 = 0,
} else void;
+const PdfSectionsOutput = if (pdf_enabled) struct {
+ pane: usize,
+ serial: u32,
+ /// The generated revision. A mismatch means the user edited the output;
+ /// reopening must not overwrite it, so a fresh output is opened instead.
+ revision: u32,
+} 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
@@ -2875,8 +3101,127 @@ const PdfView = if (pdf_enabled) struct {
reveal_viewport_h: u32 = 0,
reveal_fit: PdfFitMode = .width,
reveal_viewport_valid: bool = false,
+ /// Loaded once on first PdfSections. An empty outline is still a loaded
+ /// outline; `outline_tried` distinguishes it from the lazy initial state
+ /// and makes backend failures repeatably inert instead of retrying every
+ /// command invocation.
+ outline: ?pdf_impl.Outline = null,
+ outline_tried: bool = false,
+ sections_output: ?PdfSectionsOutput = null,
+ /// Page-space outline coordinates survive a pane split/layout rebuild.
+ /// ensurePdfLayout consumes them without invalidating any page pixels.
+ outline_reveal_pending: ?pdf_impl.OutlineInternalDestination = null,
} else void;
+/// Pure outline-to-row policy. Keeping destination resolution and title
+/// sanitisation together makes the ordinal written into a row exactly the
+/// ordinal Look later resolves; neither path invents a second flattened tree.
+const PdfSectionRows = if (pdf_enabled) struct {
+ fn usableDestination(destination: pdf_impl.OutlineDestination) ?pdf_impl.OutlineDestination {
+ return switch (destination) {
+ .internal => destination,
+ .external => |uri| if (safeHttpUri(uri)) destination else null,
+ .none => null,
+ };
+ }
+
+ fn safeHttpUri(uri: []const u8) bool {
+ // Effect.open_link is inline and cannot carry a larger URL. Omitting
+ // it here is preferable to rendering a row which can never act.
+ if (uri.len > 256) return false;
+ var has_scheme = false;
+ for (config.url_schemes) |scheme| {
+ if (std.mem.startsWith(u8, uri, scheme)) {
+ has_scheme = true;
+ break;
+ }
+ }
+ if (!has_scheme) return false;
+ for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false;
+ return true;
+ }
+
+ /// A structural node goes to the first later DFS entry still below it
+ /// which carries a usable destination. Nodes with their own unusable URI
+ /// are not structural: omit them instead of silently changing their link.
+ fn resolve(entries: []const pdf_impl.OutlineEntry, ordinal: usize) ?pdf_impl.OutlineDestination {
+ if (ordinal >= entries.len) return null;
+ const entry = entries[ordinal];
+ if (entry.destination != .none) return usableDestination(entry.destination);
+ var i = ordinal + 1;
+ while (i < entries.len and entries[i].depth > entry.depth) : (i += 1)
+ if (usableDestination(entries[i].destination)) |destination| return destination;
+ return null;
+ }
+
+ fn appendTitle(
+ out: *std.ArrayList(u8),
+ gpa: std.mem.Allocator,
+ title: ?[]const u8,
+ ) !void {
+ const raw = title orelse {
+ try out.appendSlice(gpa, "[untitled]");
+ return;
+ };
+ var i: usize = 0;
+ var wrote = false;
+ var pending_space = false;
+ while (i < raw.len) {
+ const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch {
+ pending_space = wrote;
+ i += 1;
+ continue;
+ };
+ if (i + n > raw.len) {
+ pending_space = wrote;
+ break;
+ }
+ const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch {
+ pending_space = wrote;
+ i += n;
+ continue;
+ };
+ const whitespace_or_control = cp <= 0x20 or cp == 0x7f or
+ (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029;
+ if (whitespace_or_control) {
+ pending_space = wrote;
+ } else {
+ if (pending_space) try out.append(gpa, ' ');
+ try out.appendSlice(gpa, raw[i .. i + n]);
+ wrote = true;
+ pending_space = false;
+ }
+ i += n;
+ }
+ if (!wrote) try out.appendSlice(gpa, "[empty title]");
+ }
+
+ fn render(
+ gpa: std.mem.Allocator,
+ path: []const u8,
+ entries: []const pdf_impl.OutlineEntry,
+ ) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ for (entries, 0..) |entry, ordinal| {
+ const destination = resolve(entries, ordinal) orelse continue;
+ switch (destination) {
+ .internal => |internal| try out.print(gpa, "{s}:{d}:{d} ", .{
+ path,
+ internal.page + 1,
+ ordinal + 1,
+ }),
+ .external => |uri| try out.print(gpa, "{s} ", .{uri}),
+ .none => unreachable,
+ }
+ for (0..entry.depth) |_| try out.appendSlice(gpa, " ");
+ try appendTitle(&out, gpa, entry.title);
+ try out.append(gpa, '\n');
+ }
+ return out.toOwnedSlice(gpa);
+ }
+} else struct {};
+
const PdfSlot = if (pdf_enabled) ?PdfView else void;
fn hasPdf(pane: *const Pane) bool {
@@ -3575,6 +3920,7 @@ pub const Pardes = struct {
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);
+ if (pv.outline) |*outline| outline.deinit(p.gpa);
pv.document.deinit();
}
}
@@ -3860,7 +4206,7 @@ pub const Pardes = struct {
const arena = p.scratch.allocator();
if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint(
arena,
- "pdf {d}/{d} {s} PdfFit {s} PdfTint {s}",
+ "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}",
.{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
);
if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
@@ -9097,6 +9443,9 @@ pub const Pardes = struct {
fn activatePdfPage(p: *Pardes, pane: *Pane, page: usize, reveal: bool) void {
if (comptime !pdf_enabled) return;
const pv = &(pane.pdf orelse return);
+ // Every ordinary page/navigation action supersedes an outline reveal.
+ // The outline path calls this first and installs its destination after.
+ pv.outline_reveal_pending = null;
const next = @min(page, pv.page_count -| 1);
if (next != pv.page) {
p.invalidatePdfPage(pv);
@@ -9154,6 +9503,92 @@ pub const Pardes = struct {
p.invalidateAllPdfRasters(pv);
}
+ fn ensurePdfOutline(p: *Pardes, pv: *PdfView) ?*const pdf_impl.Outline {
+ if (comptime !pdf_enabled) return null;
+ if (!pv.outline_tried) {
+ pv.outline_tried = true;
+ pv.outline = pv.document.outline(p.gpa) catch null;
+ }
+ return if (pv.outline) |*outline| outline else null;
+ }
+
+ fn isPdfSectionsOutput(pane: *const Pane) bool {
+ if (comptime !pdf_enabled) return false;
+ const file = pane.file orelse return false;
+ const output = file.output orelse return false;
+ return switch (output.from) {
+ .cmd => |builtin| builtin == .PdfSections,
+ else => false,
+ };
+ }
+
+ /// Lazily materialise this pane's cached MuPDF outline as a location list.
+ /// A clean live output is refreshed in place; an edited, closed, or reused
+ /// slot is never overwritten. Focus and n/N ownership stay on the PDF.
+ pub fn openPdfSections(p: *Pardes, id: usize) void {
+ if (comptime pdf_enabled) {
+ const pane = p.panes[id] orelse return;
+ const pv = &(pane.pdf orelse return);
+ const entries: []const pdf_impl.OutlineEntry = if (p.ensurePdfOutline(pv)) |outline|
+ outline.entries
+ else
+ &.{};
+ const content = PdfSectionRows.render(p.gpa, pv.path, entries) catch return;
+
+ if (pv.sections_output) |remembered| {
+ if (remembered.pane < p.panes.len) if (p.panes[remembered.pane]) |result| {
+ if (result.serial == remembered.serial and isPdfSectionsOutput(result)) {
+ const file = &result.file.?;
+ if (file.revision == remembered.revision) {
+ if (std.mem.eql(u8, file.content, content))
+ p.gpa.free(content)
+ else
+ file_pane.setContent(p, file, content);
+ pv.sections_output.?.revision = file.revision;
+ file.scroll = 0;
+ result.cur_row = 0;
+ result.cur_col = 0;
+ result.msel.active = false;
+ result.vsel.active = false;
+ result.nsel = 0;
+ pane.search_pane = remembered.pane;
+ pane.search_row = null;
+ p.active = id;
+ return;
+ }
+ }
+ };
+ }
+
+ const free = p.freeSlot() orelse {
+ p.gpa.free(content);
+ return;
+ };
+ const dir = std.fs.path.dirname(pv.path) orelse "/";
+ const result = output_pane.open(
+ p,
+ free,
+ dir,
+ .{ .cmd = .PdfSections },
+ "",
+ content,
+ ) catch {
+ p.gpa.free(content);
+ return;
+ };
+ pv.sections_output = .{
+ .pane = free,
+ .serial = result.serial,
+ .revision = result.file.?.revision,
+ };
+ p.placeDoc(id, free, result);
+ p.computeGeom();
+ p.active = id;
+ pane.search_pane = free;
+ pane.search_row = null;
+ }
+ }
+
fn stepPdfPage(p: *Pardes, pane: *Pane, delta: i64) void {
if (comptime !pdf_enabled) return;
const pv = pane.pdf orelse return;
@@ -9198,6 +9633,48 @@ pub const Pardes = struct {
))));
}
+ fn consumePdfOutlineReveal(pv: *PdfView, viewport: PdfViewport) void {
+ if (comptime !pdf_enabled) return;
+ const destination = pv.outline_reveal_pending orelse return;
+ pv.outline_reveal_pending = null;
+ const page = @min(destination.page, pv.page_count -| 1);
+ const size = pv.page_sizes[page];
+ const raw_y = destination.y orelse 0;
+ const y = if (std.math.isFinite(raw_y))
+ std.math.clamp(raw_y, 0, size.height)
+ else
+ 0;
+ const y_fraction = @as(f64, y) / @as(f64, size.height);
+ pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[page])) +
+ y_fraction * @as(f64, @floatFromInt(pv.page_heights[page]));
+
+ // Fit-width has no horizontal overflow. In fit-height, derive the
+ // display-space crop from page geometry so x works before this page
+ // has ever been rasterised and does not force a render merely to pan.
+ if (destination.x) |raw_x| if (pv.fit == .height and std.math.isFinite(raw_x)) {
+ const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) *
+ @as(f64, size.width) / @as(f64, size.height);
+ const viewport_width: f64 = @floatFromInt(viewport.pixel_w);
+ if (display_width > viewport_width) {
+ const x = std.math.clamp(raw_x, 0, size.width);
+ const target = @as(f64, x) / @as(f64, size.width) * display_width;
+ const overflow = display_width - viewport_width;
+ const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow);
+ pv.pan_x = @intFromFloat(@round(
+ wanted / overflow * @as(f64, std.math.maxInt(u16)),
+ ));
+ }
+ };
+
+ // The outline, not an older search hit, owns this explicit reveal.
+ // Record the current viewport so drawPdf's resize rearm agrees.
+ pv.search_reveal_pending = false;
+ pv.reveal_viewport_w = viewport.pixel_w;
+ pv.reveal_viewport_h = viewport.pixel_h;
+ pv.reveal_fit = pv.fit;
+ pv.reveal_viewport_valid = true;
+ }
+
/// Rebuild only O(page-count) point-size layout metadata. No page pixels
/// are touched, and ordinary scrolling never enters this path.
fn ensurePdfLayout(p: *Pardes, pane: *Pane, pv: *PdfView) ?PdfViewport {
@@ -9244,6 +9721,7 @@ pub const Pardes = struct {
pv.layout_anchor_pending = false;
}
}
+ consumePdfOutlineReveal(pv, viewport);
const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h));
pv.document_scroll_y = std.math.clamp(pv.document_scroll_y, 0, max_scroll);
return viewport;
@@ -9933,6 +10411,63 @@ pub const Pardes = struct {
return false;
}
+ fn revealPdfOutlineDestination(
+ p: *Pardes,
+ pane: *Pane,
+ destination: pdf_impl.OutlineInternalDestination,
+ ) void {
+ if (comptime !pdf_enabled) return;
+ p.activatePdfPage(pane, destination.page, false);
+ const pv = &pane.pdf.?;
+ pv.scroll_to_page_pending = false;
+ pv.layout_anchor_pending = false;
+ pv.outline_reveal_pending = destination;
+ // Consume immediately when geometry already exists; otherwise drawPdf
+ // consumes the same pending value after the next layout transaction.
+ _ = p.ensurePdfLayout(pane, pv);
+ }
+
+ /// PdfSections alone gives Spot.col a second meaning: the one-based DFS
+ /// ordinal written into its generated leading token. It is checked before
+ /// ordinary PDF focus so search-result columns everywhere else retain
+ /// their existing logical-hit meaning.
+ fn lookPdfSection(p: *Pardes, output_id: usize, path: []const u8, at: look.Spot) bool {
+ if (comptime !pdf_enabled) return false;
+ const output = p.panes[output_id] orelse return false;
+ if (!isPdfSectionsOutput(output)) return false;
+ if (at.line == 0 or at.col == 0 or at.end_line != 0 or
+ !look.isPdfPath(path)) return false;
+
+ var target_id: ?usize = null;
+ for (p.panes, 0..) |slot, i| {
+ const candidate = slot orelse continue;
+ if (candidate.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) {
+ target_id = i;
+ break;
+ };
+ }
+ if (target_id == null) {
+ const free = p.freeSlot() orelse return true;
+ const fresh = p.openPdfView(free, path, 0) catch return true;
+ p.placeDoc(output_id, free, fresh);
+ p.computeGeom();
+ target_id = free;
+ }
+
+ const id = target_id.?;
+ const pane = p.panes[id] orelse return true;
+ const pv = &pane.pdf.?;
+ const outline = p.ensurePdfOutline(pv) orelse return true;
+ const destination = PdfSectionRows.resolve(outline.entries, at.col - 1) orelse return true;
+ switch (destination) {
+ .internal => |internal| p.revealPdfOutlineDestination(pane, internal),
+ .external => |uri| if (uri.len <= 256) p.emit(.{ .open_link = .from(uri) }),
+ .none => unreachable,
+ }
+ p.active = id;
+ return true;
+ }
+
/// LOOK — the Look builtin's body, and so what a right click, an Enter and
/// the word `Look` all end at. Resolve `txt` against the panes' directories
/// and open (or focus) whatever it names; a word that names nothing is a
@@ -9957,6 +10492,7 @@ pub const Pardes = struct {
// canonical (realpath'd or dump-given), so match the word as-is
// here and joined onto each directory below.
const pl = look.parsePathLine(trimmed);
+ if (comptime pdf_enabled) if (p.lookPdfSection(id, pl.path, pl.at)) return;
if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.at)) return;
// The word is resolved against the pane DIRECTORIES in access
// order: the pane the click came from FIRST — its answer is the