diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-03 09:35:25 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | cd4a5b991104a2dc96a4bb7390451fb384376a9a (patch) | |
| tree | f59f04aa83266d9ddfbabcb18bad8c3277e74bd7 /src/pardes.zig | |
| parent | a69cd4a7ef5c4527e3a7cd325d49b6bf445bd810 (diff) | |
| download | pardes-cd4a5b991104a2dc96a4bb7390451fb384376a9a.tar.gz pardes-cd4a5b991104a2dc96a4bb7390451fb384376a9a.zip | |
add PDF sections output and Look navigation
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 538 |
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 |
