summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/fs.zig1
-rw-r--r--src/panes.zig2790
-rw-r--r--src/pdf_view.zig2802
3 files changed, 2804 insertions, 2789 deletions
diff --git a/src/fs.zig b/src/fs.zig
index fed15344..3dccdf7f 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -41,6 +41,7 @@ const source_files = [_]Source{
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
.{ .path = "src/image.zig", .contents = @embedFile("image.zig") },
.{ .path = "src/Terminal.zig", .contents = @embedFile("Terminal.zig") },
+ .{ .path = "src/pdf_view.zig", .contents = @embedFile("pdf_view.zig") },
.{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") },
.{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") },
.{ .path = "src/look.zig", .contents = @embedFile("look.zig") },
diff --git a/src/panes.zig b/src/panes.zig
index 0f203e6f..9bf2d92f 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -1128,2795 +1128,7 @@ pub const Mini = @import("Mini.zig");
pub const Image = @import("image.zig");
-pub const Pdf = struct {
- pub const enabled = @import("pardes_config").mupdf;
- pub const pdf = if (enabled) @import("mupdf") else struct {
- pub const PageSize = struct { width: f32, height: f32 };
- pub const Raster = struct {
- width: usize = 0,
- height: usize = 0,
- stride: usize = 0,
- len: usize = 0,
- pub const Band = struct { y: usize = 0, height: usize = 0, len: usize = 0 };
- pub fn wholePage(_: @This()) Band {
- return .{};
- }
- pub fn band(_: @This(), _: usize, _: usize) Band {
- return .{};
- }
- };
- };
- pub const Point = if (enabled) pdf.Point else void;
- pub const Quad = if (enabled) pdf.Quad else void;
- pub const Document = if (enabled) pdf.Document else opaque {};
- pub const OutlineInternalDestination = if (enabled) pdf.OutlineInternalDestination else void;
- const raster_max = 256;
- const raster_spare = 4;
- pub const page_gap_px: u32 = 8;
- const band_grain: usize = 64;
-
- pub const FitMode = if (enabled) enum { width, height } else void;
- pub const TintMode = if (enabled) pdf.TintMode else void;
- pub const TintColors = if (enabled) pdf.TintColors else void;
- pub const RenderRequest = if (enabled) pdf.RenderRequest else void;
-
- /// Dump records must remain recognizable as PDFs even in a build without
- /// MuPDF, where Look deliberately treats them as ordinary files.
- pub fn isPath(path: []const u8) bool {
- return std.ascii.endsWithIgnoreCase(path, ".pdf");
- }
-
- pub const RasterPolicy = struct {
- dpi: u16,
- max_dimension: u16,
- match_viewport: bool,
- };
-
- pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 {
- if (std.mem.startsWith(u8, saved_tag, path) and std.mem.startsWith(u8, saved_tag[path.len..], " [")) {
- const start = path.len + 2;
- const end = std.mem.indexOfScalarPos(u8, saved_tag, start, ']') orelse return null;
- var parts = std.mem.splitScalar(u8, saved_tag[start..end], '/');
- _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null;
- _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null;
- if (parts.next() != null) return null;
- return saved_tag[0 .. end + 1];
- }
- if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null;
- const marker = " PdfSections ";
- const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null;
- const path_at = marker_at + marker.len;
- if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null;
- return saved_tag[0 .. path_at + path.len];
- }
-
- pub const TintKey = if (enabled) struct {
- mode: TintMode,
- colors: pdf.TintColors,
-
- pub fn eql(a: @This(), b: @This()) bool {
- return a.mode == b.mode and
- (a.mode == .disabled or std.meta.eql(a.colors, b.colors));
- }
- } else void;
-
- pub const Highlight = if (enabled) pdf.Highlight else void;
- pub const Highlights = if (enabled) struct {
- const Page = struct { page: usize, items: []const Highlight };
- pages: [3]Page = undefined,
- len: usize = 0,
- /// Repaint whenever the set changes, not only when a page's raster was
- /// invalidated (`HighlightInput.live`).
- live: bool = false,
-
- pub fn forPage(highlights: @This(), page: usize) []const Highlight {
- for (highlights.pages[0..highlights.len]) |group|
- if (group.page == page) return group.items;
- return &.{};
- }
- } else void;
-
- pub const HighlightInput = if (enabled) struct {
- hover_quads: []const Quad = &.{},
- hover_page: ?usize = null,
- hover_color: [3]u8,
- selection_color: [3]u8,
- live: bool = false,
- } else void;
-
- pub fn buildHighlights(
- state: *const State,
- arena: std.mem.Allocator,
- input: HighlightInput,
- ) !Highlights {
- if (comptime !enabled) return;
- const search_len = if (state.search_results) |results| results.quads.len else 0;
- const selection_len = if (state.selection) |selection| selection.quads.len else 0;
- const items = try arena.alloc(Highlight, input.hover_quads.len + search_len + selection_len);
- var result: Highlights = .{ .live = input.live };
- var n: usize = 0;
- // Hover, search and selection can belong to three different pages.
- // Group them once so raster rendering can borrow a contiguous slice.
- for ([_]?usize{ input.hover_page, state.page, state.selection_page }) |candidate| {
- const page = candidate orelse continue;
- var seen = false;
- for (result.pages[0..result.len]) |group| {
- if (group.page == page) seen = true;
- }
- if (seen) continue;
- const start = n;
- if (input.hover_page == page) for (input.hover_quads) |quad| {
- items[n] = pdf.Highlight.init(quad, .{
- input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c,
- }, .custom);
- n += 1;
- };
- if (state.page == page) if (state.search_results) |results| {
- for (results.quads) |item| {
- items[n] = pdf.Highlight.init(item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search);
- n += 1;
- }
- };
- if (state.selection_page == page) if (state.selection) |selection| {
- for (selection.quads) |quad| {
- items[n] = pdf.Highlight.init(quad, .{
- input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78,
- }, .selection);
- n += 1;
- }
- };
- result.pages[result.len] = .{ .page = page, .items = items[start..n] };
- result.len += 1;
- }
- return result;
- }
-
- test "PDF selections retain their owning page across page focus" {
- if (comptime !enabled) return;
- const gpa = std.testing.allocator;
- var state = try State.open(gpa, "docs/design.pdf", 1);
- defer state.deinit(gpa);
- try std.testing.expect(state.page_count > 1);
- var found = try state.document.search(gpa, 0, "Pardes");
- defer found.deinit(gpa);
- const quad = found.quads[0].quad;
- const point: Point = .{
- .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4,
- .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4,
- };
- try std.testing.expectEqual(.changed, state.setSelection(gpa, point, point, true));
- const selected = try gpa.dupe(u8, state.selection_text);
- defer gpa.free(selected);
- try std.testing.expect(selected.len > 0);
- try std.testing.expect(state.focusLocation(gpa, 2, 0));
- try std.testing.expectEqual(@as(?usize, 0), state.selection_page);
- try std.testing.expectEqualStrings(selected, state.selection_text);
- var arena = std.heap.ArenaAllocator.init(gpa);
- defer arena.deinit();
- const highlights = try buildHighlights(&state, arena.allocator(), .{
- .hover_quads = &.{quad},
- .hover_page = 1,
- .hover_color = .{ 1, 2, 3 },
- .selection_color = .{ 4, 5, 6 },
- });
- try std.testing.expectEqual(state.selection.?.quads.len, highlights.forPage(0).len);
- try std.testing.expectEqual(@as(usize, 1), highlights.forPage(1).len);
- try std.testing.expectEqual(@as(usize, 0), highlights.forPage(2).len);
- try std.testing.expect(state.focusLocation(gpa, 1, 0));
- try std.testing.expectEqualStrings(selected, state.selection_text);
- try std.testing.expectEqual(.unchanged, state.setSelection(gpa, point, point, true));
- try std.testing.expect(state.focusLocation(gpa, 2, 0));
- state.rasters[0] = .{ .valid = true, .page = 0, .tried = true, .decorated = true };
- state.rasters_len = 1;
- try std.testing.expectEqual(.changed, state.setSelection(gpa, point, point, false));
- try std.testing.expectEqual(@as(?usize, 1), state.selection_page);
- try std.testing.expect(!state.rasters[0].tried);
- state.clearSelection(gpa);
- try std.testing.expect(state.selection_page == null);
- try std.testing.expectEqual(@as(usize, 0), state.selection_text.len);
- }
-
- pub const Raster = if (enabled) struct {
- valid: bool = false,
- page: usize = 0,
- rgba: []u8 = &.{},
- /// Full page shape; rgba contains only the band below.
- iw: usize = 0,
- ih: usize = 0,
- band_y: usize = 0,
- band_h: usize = 0,
- request: pdf.RenderRequest = .{},
- request_valid: bool = false,
- tried: bool = false,
- decorated: bool = false,
- tint_key: ?TintKey = null,
- revision: u32 = 0,
- /// `rgba` before its highlights and tint, kept once the page has been
- /// highlighted: a highlight change repaints rows from it instead of
- /// rendering the page again.
- clean: []u8 = &.{},
- /// The highlights baked into `rgba` (gpa-owned), so a change repaints
- /// only the rows of the quads that differ.
- baked: []Highlight = &.{},
- /// `baked` is a true record (false after a render could not copy it).
- baked_valid: bool = false,
- /// Page rows changed since revision `patch_from`, the one last handed
- /// to a shell; 0 when the pixels were replaced wholesale since.
- patch_from: u32 = 0,
- patch_rows: pdf.Document.Rows = .{ .start = 0, .end = 0 },
- } else void;
-
- pub const SectionsOutput = if (enabled) struct {
- pane: usize,
- serial: u32,
- revision: u32,
- } else void;
-
- pub const SelectionUpdate = enum { failed, stationary, unchanged, changed };
-
- pub const State = if (enabled) struct {
- path: []u8,
- document: Document,
- page: usize = 0,
- page_count: usize,
- page_sizes: []pdf.PageSize,
- page_starts: []u64,
- page_heights: []u32,
- document_height: u64 = 0,
- layout_viewport_w: u32 = 0,
- layout_viewport_h: u32 = 0,
- layout_fit: FitMode = .width,
- layout_valid: bool = false,
- document_scroll_y: f64 = 0,
- scroll_to_page_pending: bool = true,
- rasters: [raster_max]Raster = undefined,
- rasters_len: usize = 0,
- spare: [raster_spare][]u8 = @splat(&.{}),
- spare_len: usize = 0,
- layout_anchor_pending: bool = false,
- layout_anchor_page: usize = 0,
- layout_anchor_fraction: f64 = 0,
- next_raster_revision: u32 = 0,
- scroll_travel: f64 = 0,
- fit: FitMode = .width,
- tint: TintMode = .filtered,
- pan_x: u16 = 0,
- pan_y: u16 = 0,
- highlights_dirty: bool = false,
- search_reveal_pending: bool = false,
- search_results: ?pdf.SearchResults = null,
- selection: ?pdf.Selection = null,
- selection_page: ?usize = null,
- selection_text: []u8 = &.{},
- selection_anchor: ?Point = null,
- selection_head: ?Point = null,
- drag_anchor: ?Point = null,
- drag_head: ?Point = null,
- text: []u8 = &.{},
- text_tried: bool = false,
- text_scroll: usize = 0,
- text_scroll_clamp_pending: bool = false,
- search_query: []u8 = &.{},
- search_hit: usize = 0,
- reveal_viewport_w: u32 = 0,
- reveal_viewport_h: u32 = 0,
- reveal_fit: FitMode = .width,
- reveal_viewport_valid: bool = false,
- outline: ?pdf.Outline = null,
- outline_tried: bool = false,
- sections_output: ?SectionsOutput = null,
- outline_reveal_pending: ?pdf.OutlineInternalDestination = null,
-
- /// Read whole and opened from memory (the bridge copies), never from
- /// the file itself: MuPDF reads a file lazily at every page, and the
- /// path may be a mount this editor serves, which only a read that
- /// gives the turn up can come back from (`filesystem.readFile`).
- pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() {
- const local = filesystem.localPath(path) orelse return error.NonLocalPath;
- const bytes = try filesystem.readFile(gpa, local);
- defer gpa.free(bytes);
- return initDocument(gpa, path, try Document.openBytes(bytes), page_one_based);
- }
-
- pub fn openBytes(gpa: std.mem.Allocator, path: []const u8, bytes: []const u8, page_one_based: usize) !@This() {
- return initDocument(gpa, path, try Document.openBytes(bytes), page_one_based);
- }
-
- fn initDocument(gpa: std.mem.Allocator, path: []const u8, opened: Document, page_one_based: usize) !@This() {
- var document = opened;
- errdefer document.deinit();
- const page_sizes = try gpa.alloc(pdf.PageSize, document.pages);
- errdefer gpa.free(page_sizes);
- for (page_sizes, 0..) |*size, page| size.* = try document.pageSize(page);
- const page_starts = try gpa.alloc(u64, document.pages);
- errdefer gpa.free(page_starts);
- const page_heights = try gpa.alloc(u32, document.pages);
- errdefer gpa.free(page_heights);
- const owned_path = try gpa.dupe(u8, path);
- errdefer gpa.free(owned_path);
- return .{
- .path = owned_path,
- .document = document,
- .page = if (page_one_based > 0)
- @min(page_one_based - 1, document.pages - 1)
- else
- 0,
- .page_count = document.pages,
- .page_sizes = page_sizes,
- .page_starts = page_starts,
- .page_heights = page_heights,
- };
- }
-
- pub fn reload(state: *@This(), gpa: std.mem.Allocator) !void {
- const preserve_anchor = !state.scroll_to_page_pending and
- (state.layout_anchor_pending or
- (state.layout_valid and state.page_count > 0 and state.document_height > 0));
- var anchor_page: usize = state.layout_anchor_page;
- var anchor_fraction: f64 = state.layout_anchor_fraction;
- if (preserve_anchor and !state.layout_anchor_pending) {
- anchor_page = pageAtOffset(state, state.document_scroll_y);
- const start: f64 = @floatFromInt(state.page_starts[anchor_page]);
- const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[anchor_page]));
- anchor_fraction = std.math.clamp(
- (state.document_scroll_y - start) / height,
- 0,
- 1,
- );
- }
-
- var fresh = try @This().open(gpa, state.path, state.page + 1);
- errdefer fresh.deinit(gpa);
- if (state.search_query.len > 0)
- fresh.search_query = try gpa.dupe(u8, state.search_query);
-
- fresh.fit = state.fit;
- fresh.tint = state.tint;
- fresh.pan_x = state.pan_x;
- fresh.pan_y = state.pan_y;
- fresh.text_scroll = state.text_scroll;
- fresh.text_scroll_clamp_pending = true;
- fresh.search_hit = state.search_hit;
- fresh.highlights_dirty = fresh.search_query.len > 0;
- fresh.search_reveal_pending = state.search_reveal_pending;
- fresh.reveal_viewport_w = state.reveal_viewport_w;
- fresh.reveal_viewport_h = state.reveal_viewport_h;
- fresh.reveal_fit = state.reveal_fit;
- fresh.reveal_viewport_valid = state.reveal_viewport_valid;
- // Revisions are part of the backend texture key. Resetting this counter
- // while the pane serial stays live can alias a cached pre-reload page.
- fresh.next_raster_revision = state.next_raster_revision;
- fresh.sections_output = state.sections_output;
- if (preserve_anchor) {
- fresh.scroll_to_page_pending = false;
- fresh.layout_anchor_pending = true;
- fresh.layout_anchor_page = anchor_page;
- fresh.layout_anchor_fraction = anchor_fraction;
- }
-
- var old = state.*;
- state.* = fresh;
- old.deinit(gpa);
- }
-
- pub fn invalidateRaster(state: *@This(), page: usize) void {
- if (rasterForPage(state, page)) |raster| raster.tried = false;
- }
-
- pub fn invalidateAllRasters(state: *@This()) void {
- for (state.rasters[0..state.rasters_len]) |*raster| {
- if (raster.valid) raster.tried = false;
- }
- }
-
- fn retireRgba(state: *@This(), gpa: std.mem.Allocator, rgba: []u8) void {
- if (rgba.len == 0) return;
- if (state.spare_len == state.spare.len) return gpa.free(rgba);
- state.spare[state.spare_len] = rgba;
- state.spare_len += 1;
- }
-
- fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void {
- if (raster.clean.len > 0) gpa.free(raster.clean);
- gpa.free(raster.baked);
- state.retireRgba(gpa, raster.rgba);
- raster.* = .{};
- }
-
- fn claimRgba(state: *@This(), gpa: std.mem.Allocator, bytes: usize) ?[]u8 {
- for (state.spare[0..state.spare_len], 0..) |candidate, index| {
- if (candidate.len != bytes) continue;
- state.spare_len -= 1;
- state.spare[index] = state.spare[state.spare_len];
- return candidate;
- }
- return gpa.alloc(u8, bytes) catch null;
- }
-
- fn trimSpares(state: *@This(), gpa: std.mem.Allocator) void {
- while (state.spare_len > 1) {
- state.spare_len -= 1;
- gpa.free(state.spare[state.spare_len]);
- }
- }
-
- fn dropSearchResults(state: *@This(), gpa: std.mem.Allocator) void {
- if (state.search_results) |*results| results.deinit(gpa);
- state.search_results = null;
- }
-
- fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void {
- if (state.selection) |*selection| selection.deinit(gpa);
- state.selection = null;
- state.selection_page = null;
- if (state.selection_text.len > 0) gpa.free(state.selection_text);
- state.selection_text = &.{};
- state.selection_anchor = null;
- state.selection_head = null;
- }
-
- pub fn setSelection(
- state: *@This(),
- gpa: std.mem.Allocator,
- start: Point,
- end: Point,
- invalidate_raster: bool,
- ) SelectionUpdate {
- if (state.selection != null and state.selection_page == state.page and
- state.selection_anchor != null and state.selection_head != null and
- state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and
- state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged;
- var selection = state.document.select(gpa, state.page, start, end) catch return .failed;
- const text = state.document.copySelection(
- gpa,
- state.page,
- selection.start,
- selection.end,
- ) catch {
- selection.deinit(gpa);
- return .failed;
- };
-
- const old_page = state.selection_page;
- state.dropSelection(gpa);
- state.selection = selection;
- state.selection_page = state.page;
- state.selection_text = text;
- state.selection_anchor = start;
- state.selection_head = end;
- if (old_page) |page| if (page != state.page) state.invalidateRaster(page);
- if (invalidate_raster) state.invalidateRaster(state.page);
- return .changed;
- }
-
- pub fn clearDrag(state: *@This()) void {
- state.drag_anchor = null;
- state.drag_head = null;
- }
-
- pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void {
- const page = state.selection_page;
- state.dropSelection(gpa);
- if (page) |selected_page| state.invalidateRaster(selected_page);
- }
-
- pub fn cancelChrome(state: *@This(), gpa: std.mem.Allocator) void {
- state.clearDrag();
- state.clearSelection(gpa);
- if (state.search_query.len == 0) return;
- state.dropSearchQuery(gpa);
- state.invalidateRaster(state.page);
- }
-
- fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void {
- state.dropSearchResults(gpa);
- state.clearDrag();
- if (state.text.len > 0) gpa.free(state.text);
- state.text = &.{};
- state.text_tried = false;
- state.text_scroll = 0;
- state.text_scroll_clamp_pending = false;
- state.highlights_dirty = state.search_query.len > 0;
- state.search_reveal_pending = state.search_query.len > 0;
- state.search_hit = 0;
- }
-
- fn dropSearchQuery(state: *@This(), gpa: std.mem.Allocator) void {
- if (state.search_query.len > 0) gpa.free(state.search_query);
- state.search_query = &.{};
- state.search_hit = 0;
- state.dropSearchResults(gpa);
- state.highlights_dirty = false;
- state.search_reveal_pending = false;
- }
-
- pub fn setSearchQuery(state: *@This(), gpa: std.mem.Allocator, query: []const u8) !void {
- if (std.mem.eql(u8, state.search_query, query)) return;
- const owned = try gpa.dupe(u8, query);
- state.dropSearchQuery(gpa);
- state.search_query = owned;
- state.highlights_dirty = query.len > 0;
- state.search_reveal_pending = query.len > 0;
- state.invalidateRaster(state.page);
- }
-
- pub fn ensureText(state: *@This(), gpa: std.mem.Allocator) []const u8 {
- if (!state.text_tried) {
- state.text_tried = true;
- state.text = state.document.pageText(gpa, state.page) catch &.{};
- }
- if (state.text_scroll_clamp_pending) {
- const lines = std.mem.count(u8, state.text, "\n") + 1;
- state.text_scroll = @min(state.text_scroll, lines - 1);
- state.text_scroll_clamp_pending = false;
- }
- return state.text;
- }
-
- pub fn resolveSearch(state: *@This(), gpa: std.mem.Allocator) void {
- if (!state.highlights_dirty) return;
- state.highlights_dirty = false;
- state.dropSearchResults(gpa);
- if (state.search_query.len == 0) return;
- const results = state.document.search(gpa, state.page, state.search_query) catch return;
- state.search_hit = if (results.hit_count == 0)
- 0
- else
- @min(state.search_hit, results.hit_count - 1);
- state.search_results = results;
- }
-
- pub fn ensureOutline(state: *@This(), gpa: std.mem.Allocator) ?*const pdf.Outline {
- if (!state.outline_tried) {
- state.outline_tried = true;
- state.outline = state.document.outline(gpa) catch null;
- }
- return if (state.outline) |*outline| outline else null;
- }
-
- pub fn renderSections(
- state: *@This(),
- pdf_gpa: std.mem.Allocator,
- output_gpa: std.mem.Allocator,
- ) ![]u8 {
- const entries: []const pdf.OutlineEntry = if (state.ensureOutline(pdf_gpa)) |outline|
- outline.entries
- else
- &.{};
- return SectionRows.render(output_gpa, state.path, entries);
- }
-
- pub fn sectionDestination(
- state: *@This(),
- gpa: std.mem.Allocator,
- ordinal: usize,
- ) ?pdf.OutlineDestination {
- const outline = state.ensureOutline(gpa) orelse return null;
- return SectionRows.resolve(outline.entries, ordinal);
- }
-
- /// Apply the one-based page/hit location encoded in a PDF search row.
- /// Returns whether host pane cursor chrome must be reset.
- pub fn focusLocation(
- state: *@This(),
- gpa: std.mem.Allocator,
- line: usize,
- column: usize,
- ) bool {
- const changed = line > 0 and state.activatePage(gpa, line - 1, true);
- if (column > 0 and state.search_query.len > 0) {
- state.search_hit = column - 1;
- state.search_reveal_pending = true;
- }
- return changed;
- }
-
- /// Change the document page while leaving pane cursor/selection chrome to
- /// the UI adapter. Returns whether that pane-local chrome must be reset.
- pub fn activatePage(
- state: *@This(),
- gpa: std.mem.Allocator,
- page: usize,
- reveal: bool,
- ) bool {
- state.outline_reveal_pending = null;
- const next = @min(page, state.page_count -| 1);
- const changed = next != state.page;
- if (changed) {
- state.invalidatePage(gpa);
- state.page = next;
- }
- if (reveal) {
- state.scroll_to_page_pending = true;
- if (state.layout_valid) {
- state.document_scroll_y = @floatFromInt(state.page_starts[next]);
- state.scroll_to_page_pending = false;
- }
- } else {
- state.search_reveal_pending = false;
- }
- return changed;
- }
-
- pub fn toggleFit(state: *@This()) void {
- state.fit = if (state.fit == .width) .height else .width;
- state.pan_x = 0;
- state.pan_y = 0;
- state.layout_valid = false;
- state.scroll_to_page_pending = true;
- state.search_reveal_pending = state.search_query.len > 0;
- }
-
- pub fn toggleTint(state: *@This()) void {
- state.tint = state.tint.next();
- state.invalidateAllRasters();
- }
-
- pub fn queueOutlineReveal(
- state: *@This(),
- destination: pdf.OutlineInternalDestination,
- ) void {
- state.scroll_to_page_pending = false;
- state.layout_anchor_pending = false;
- state.outline_reveal_pending = destination;
- }
-
- pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void {
- gpa.free(state.path);
- for (state.rasters[0..state.rasters_len]) |raster| {
- if (raster.rgba.len > 0) gpa.free(raster.rgba);
- if (raster.clean.len > 0) gpa.free(raster.clean);
- gpa.free(raster.baked);
- }
- for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba);
- gpa.free(state.page_sizes);
- gpa.free(state.page_starts);
- gpa.free(state.page_heights);
- if (state.text.len > 0) gpa.free(state.text);
- if (state.search_query.len > 0) gpa.free(state.search_query);
- if (state.search_results) |*results| results.deinit(gpa);
- if (state.selection) |*selection| selection.deinit(gpa);
- if (state.selection_text.len > 0) gpa.free(state.selection_text);
- if (state.outline) |*outline| outline.deinit(gpa);
- state.document.deinit();
- state.* = undefined;
- }
- } else void;
-
- pub const SearchOutput = struct {
- bytes: usize = 0,
- rows: usize = 0,
- anchor: ?usize = null,
- };
-
- pub fn textLines(
- state: *State,
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- ) ![]const []const u8 {
- if (comptime !enabled) return &.{};
- const page_text = state.ensureText(gpa);
- const lines = try arena.alloc([]const u8, std.mem.count(u8, page_text, "\n") + 1);
- var it = std.mem.splitScalar(u8, page_text, '\n');
- var n: usize = 0;
- while (it.next()) |line| : (n += 1) lines[n] = line;
- return lines;
- }
-
- pub fn visibleText(
- state: *State,
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- max_rows: usize,
- ) ![]const u8 {
- if (comptime !enabled) return "";
- const page_text = state.ensureText(gpa);
- var start: usize = 0;
- for (0..state.text_scroll) |_| {
- const newline = std.mem.indexOfScalarPos(u8, page_text, start, '\n') orelse
- return arena.dupe(u8, "");
- start = newline + 1;
- }
- if (max_rows == 0) return arena.dupe(u8, "");
-
- var end = start;
- var row: usize = 0;
- while (row < max_rows) : (row += 1) {
- const newline = std.mem.indexOfScalarPos(u8, page_text, end, '\n') orelse {
- end = page_text.len;
- break;
- };
- if (row + 1 == max_rows) {
- end = newline;
- break;
- }
- end = newline + 1;
- }
- return arena.dupe(u8, page_text[start..end]);
- }
-
- /// Materialize exact MuPDF logical hits as `path:PAGE:HIT query` rows. The
- /// same hit numbering drives persistent highlights and later reveal actions.
- pub fn searchRows(
- state: *State,
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- pattern: []const u8,
- from_cursor: bool,
- out: []u8,
- ) !SearchOutput {
- if (comptime !enabled) return .{};
- try state.setSearchQuery(gpa, pattern);
- const shown = std.fs.path.basename(state.path);
- var result: SearchOutput = .{};
- const max_hits = 512;
- var snippet_len = @min(pattern.len, 200);
- while (snippet_len > 0 and snippet_len < pattern.len and pattern[snippet_len] & 0xc0 == 0x80)
- snippet_len -= 1;
- const snippet = pattern[0..snippet_len];
- for (0..state.page_count) |page| {
- if (result.rows >= max_hits) break;
- var found = try state.document.search(gpa, page, pattern);
- defer found.deinit(gpa);
-
- const cursor_hit: ?usize = if (from_cursor and page == state.page and state.selection_page == page) cursor: {
- const selection = state.selection orelse break :cursor null;
- for (found.quads) |item| {
- const q = item.quad;
- const center: Point = .{
- .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;
- const line = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
- shown, page + 1, hit_index + 1, snippet,
- });
- if (line.len > out.len - result.bytes) return result;
- if (from_cursor and
- (page < state.page or
- (page == state.page and cursor_hit != null and hit_index <= cursor_hit.?)))
- result.anchor = result.rows;
- @memcpy(out[result.bytes..][0..line.len], line);
- result.bytes += line.len;
- result.rows += 1;
- }
- }
- return result;
- }
-
- pub const Viewport = struct { pixel_w: u32, pixel_h: u32 };
- pub const VisiblePages = struct { first: usize = 0, len: usize = 0 };
- pub const PanAxis = enum { horizontal, vertical };
- pub const PanResult = enum { moved, edge, unavailable };
- pub const ScrollResult = struct { active_page: usize };
- pub const CellPixels = struct { w: u16, h: u16 };
- pub const NormalHost = enum {
- none,
- leader,
- command_line,
- search,
- search_forward,
- search_backward,
- };
- pub const NormalResult = struct {
- host: NormalHost = .none,
- page_changed: bool = false,
- };
-
- const PlacedGeometry = struct {
- geometry: image.NativeGeometry,
- pixel_offset_y: f32,
- };
-
- pub const PlacedRaster = if (enabled) struct {
- page: usize,
- revision: u32,
- fit: FitMode,
- pan_x: u16,
- geometry: image.NativeGeometry,
- pixel_offset_y: f32,
- rgba: []const u8,
- width: usize,
- band_height: usize,
- } else void;
-
- const VisibleRows = struct {
- base: image.NativeGeometry,
- y0: u32,
- y1: u32,
- dst_y: u32,
- dst_h: u32,
- pixel_offset_y: f32,
- };
-
- pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest {
- if (comptime !enabled) return;
- return .{
- .dpi = policy.dpi,
- .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0,
- .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0,
- .max_dimension = policy.max_dimension,
- };
- }
-
- fn pageHeight(size: pdf.PageSize, viewport: Viewport, fit: FitMode) u32 {
- if (comptime !enabled) return 0;
- if (fit == .height) return viewport.pixel_h;
- const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) *
- @as(f64, size.height) / @as(f64, size.width);
- return @max(1, @as(u32, @intFromFloat(@min(
- @as(f64, @floatFromInt(std.math.maxInt(u32))),
- @round(scaled),
- ))));
- }
-
- fn pageAtOffset(state: *const State, offset: f64) usize {
- if (comptime !enabled) return 0;
- const y: u64 = @intFromFloat(std.math.clamp(
- @floor(offset),
- 0,
- @as(f64, @floatFromInt(state.document_height -| 1)),
- ));
- var lo: usize = 0;
- var hi: usize = state.page_count;
- while (lo + 1 < hi) {
- const mid = lo + (hi - lo) / 2;
- if (state.page_starts[mid] <= y) lo = mid else hi = mid;
- }
- const end = state.page_starts[lo] + state.page_heights[lo];
- return if (y >= end and lo + 1 < state.page_count) lo + 1 else lo;
- }
-
- fn pageVisible(state: *const State, page: usize, viewport: Viewport) bool {
- if (comptime !enabled) return false;
- const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y;
- const bottom = top + @as(f64, @floatFromInt(state.page_heights[page]));
- return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h));
- }
-
- pub fn visiblePages(state: *const State, viewport: Viewport) VisiblePages {
- if (comptime !enabled) return .{};
- var out: VisiblePages = .{};
- var page = pageAtOffset(state, state.document_scroll_y);
- if (page > 0 and pageVisible(state, page - 1, viewport)) page -= 1;
- out.first = page;
- while (page < state.page_count) : (page += 1) {
- const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y;
- if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break;
- if (pageVisible(state, page, viewport)) out.len += 1;
- }
- return out;
- }
-
- fn visibleContains(visible: VisiblePages, page: usize) bool {
- return page >= visible.first and page - visible.first < visible.len;
- }
-
- pub fn rasterForPage(state: *State, page: usize) ?*Raster {
- if (comptime !enabled) return null;
- for (state.rasters[0..state.rasters_len]) |*raster|
- if (raster.valid and raster.page == page) return raster;
- return null;
- }
-
- fn rasterForPageConst(state: *const State, page: usize) ?*const Raster {
- if (comptime !enabled) return null;
- for (state.rasters[0..state.rasters_len]) |*raster|
- if (raster.valid and raster.page == page) return raster;
- return null;
- }
-
- fn visibleRows(
- state: *const State,
- viewport: Viewport,
- page: usize,
- iw: usize,
- ih: usize,
- ) ?VisibleRows {
- if (comptime !enabled) return null;
- const page_h = state.page_heights[page];
- const base = image.nativeGeometry(
- iw,
- ih,
- viewport.pixel_w,
- page_h,
- switch (state.fit) {
- .width => .width,
- .height => .height,
- },
- state.pan_x,
- 0,
- ) orelse return null;
- if (base.dst.h == 0 or base.src.h == 0) return null;
-
- const scroll_floor = @floor(state.document_scroll_y);
- const fractional: f32 = @floatCast(state.document_scroll_y - scroll_floor);
- const scroll_i: i64 = @intFromFloat(@min(
- scroll_floor,
- @as(f64, @floatFromInt(std.math.maxInt(i64))),
- ));
- const start_i: i64 = @intCast(@min(
- state.page_starts[page],
- @as(u64, std.math.maxInt(i64)),
- ));
- const full_y = start_i - scroll_i + @as(i64, base.dst.y);
- const full_bottom = full_y + @as(i64, base.dst.h);
- const visible_y = @max(@as(i64, 0), full_y);
- const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom);
- if (visible_bottom <= visible_y) return null;
-
- const rel_y0: u64 = @intCast(visible_y - full_y);
- const rel_y1: u64 = @intCast(visible_bottom - full_y);
- const src_y0: u32 = base.src.y + @as(u32, @intCast(
- rel_y0 * base.src.h / base.dst.h,
- ));
- const src_y1: u32 = base.src.y + @as(u32, @intCast(@min(
- @as(u64, base.src.h),
- (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h,
- )));
- if (src_y1 <= src_y0) return null;
- return .{
- .base = base,
- .y0 = src_y0,
- .y1 = src_y1,
- .dst_y = @intCast(visible_y),
- .dst_h = @intCast(visible_bottom - visible_y),
- .pixel_offset_y = -fractional,
- };
- }
-
- fn placedGeometry(
- state: *const State,
- raster: *const Raster,
- viewport: Viewport,
- page: usize,
- ) ?PlacedGeometry {
- if (comptime !enabled) return null;
- const rows = visibleRows(state, viewport, page, raster.iw, raster.ih) orelse return null;
- const band_y: u32 = @intCast(raster.band_y);
- const band_end: u32 = @intCast(raster.band_y + raster.band_h);
- if (rows.y0 < band_y or rows.y1 > band_end) return null;
- return .{
- .geometry = .{
- .src = .{
- .x = rows.base.src.x,
- .y = rows.y0 - band_y,
- .w = rows.base.src.w,
- .h = rows.y1 - rows.y0,
- },
- .dst = .{
- .x = rows.base.dst.x,
- .y = rows.dst_y,
- .w = rows.base.dst.w,
- .h = rows.dst_h,
- },
- },
- .pixel_offset_y = rows.pixel_offset_y,
- };
- }
-
- pub fn placedRaster(state: *const State, viewport: Viewport, page: usize) ?PlacedRaster {
- if (comptime !enabled) return null;
- const raster = rasterForPageConst(state, page) orelse return null;
- if (raster.rgba.len == 0) return null;
- const placed = placedGeometry(state, raster, viewport, page) orelse return null;
- return .{
- .page = page,
- .revision = raster.revision,
- .fit = state.fit,
- .pan_x = state.pan_x,
- .geometry = placed.geometry,
- .pixel_offset_y = placed.pixel_offset_y,
- .rgba = raster.rgba,
- .width = raster.iw,
- .band_height = raster.band_h,
- };
- }
-
- pub fn activeGeometry(state: *const State, viewport: Viewport) ?image.NativeGeometry {
- if (comptime !enabled) return null;
- const raster = rasterForPageConst(state, state.page) orelse return null;
- const placed = placedGeometry(state, raster, viewport, state.page) orelse return null;
- return placed.geometry;
- }
-
- pub fn pageAtViewportY(state: *const State, local_y: f64) ?usize {
- if (comptime !enabled) return null;
- if (!state.layout_valid or !std.math.isFinite(local_y) or local_y < 0) return null;
- const document_y = state.document_scroll_y + local_y;
- const page = pageAtOffset(state, document_y);
- const start: f64 = @floatFromInt(state.page_starts[page]);
- if (document_y < start or
- document_y >= start + @as(f64, @floatFromInt(state.page_heights[page]))) return null;
- return page;
- }
-
- pub fn pageReady(state: *const State, viewport: Viewport, page: usize) bool {
- if (comptime !enabled) return false;
- return placedRaster(state, viewport, page) != null;
- }
-
- pub fn nativeReady(state: *const State, viewport: Viewport) bool {
- if (comptime !enabled) return false;
- for (state.rasters[0..state.rasters_len]) |*raster| {
- if (raster.valid and raster.rgba.len > 0 and
- placedGeometry(state, raster, viewport, raster.page) != null) return true;
- }
- return false;
- }
-
- pub fn pointAtPage(
- state: *const State,
- viewport: Viewport,
- page: usize,
- px: i64,
- py: i64,
- clamp_to_page: bool,
- ) ?Point {
- if (comptime !enabled) return null;
- const raster = rasterForPageConst(state, page) orelse return null;
- if (raster.rgba.len == 0) return null;
- const placed = placedGeometry(state, raster, viewport, page) orelse return null;
- return pointAtGeometry(
- placed.geometry,
- placed.pixel_offset_y,
- raster.iw,
- raster.ih,
- raster.band_y,
- px,
- py,
- clamp_to_page,
- );
- }
-
- fn slotShape(slot: *const Raster) pdf.Raster {
- const stride = slot.iw * 4;
- return .{ .width = slot.iw, .height = slot.ih, .stride = stride, .len = stride * slot.ih };
- }
-
- fn flinging(state: *const State, viewport: Viewport) bool {
- if (comptime !enabled) return false;
- return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h));
- }
-
- fn wantedBand(
- state: *const State,
- viewport: Viewport,
- page: usize,
- shape: pdf.Raster,
- is_flinging: bool,
- ) pdf.Raster.Band {
- if (!is_flinging) return shape.wholePage();
- const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse
- return shape.wholePage();
- const first = (@as(usize, rows.y0) / band_grain) * band_grain;
- const last = std.math.divCeil(usize, @as(usize, rows.y1), band_grain) catch
- return shape.wholePage();
- return shape.band(first, last * band_grain - first);
- }
-
- fn reconcile(
- state: *State,
- gpa: std.mem.Allocator,
- request: RenderRequest,
- tint_key: TintKey,
- highlights: Highlights,
- visible: VisiblePages,
- viewport: Viewport,
- ) void {
- if (comptime !enabled) return;
- const tz = tracy.zone(@src(), "pdf.reconcile");
- defer tz.end();
-
- // Remove first so arriving pages can claim departing page buffers. Only
- // the final visible set can be seen, so a fling skips crossed-over pages.
- var index: usize = 0;
- while (index < state.rasters_len) {
- if (visibleContains(visible, state.rasters[index].page)) {
- index += 1;
- continue;
- }
- state.retireRaster(gpa, &state.rasters[index]);
- state.rasters_len -= 1;
- if (index != state.rasters_len)
- state.rasters[index] = state.rasters[state.rasters_len];
- }
-
- const is_flinging = flinging(state, viewport);
- var page = visible.first;
- const end = visible.first + visible.len;
- while (page < end) : (page += 1) {
- var raster = rasterForPage(state, page);
- if (raster == null) {
- if (state.rasters_len == state.rasters.len) continue;
- state.rasters[state.rasters_len] = .{ .valid = true, .page = page };
- state.rasters_len += 1;
- raster = &state.rasters[state.rasters_len - 1];
- }
- const slot = raster.?;
- const page_highlights = highlights.forPage(page);
- const decorated = page_highlights.len > 0;
- const base_stale = !slot.request_valid or !slot.request.eql(request) or
- slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or
- slot.rgba.len == 0 or slot.band_h == 0;
- const uncovered = !base_stale and uncovered: {
- const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging);
- break :uncovered slot.band_y > want.y or
- slot.band_y + slot.band_h < want.y + want.height;
- };
- if (!base_stale and !uncovered) {
- if (slot.tried and slot.decorated == decorated and
- !(highlights.live and !sameHighlights(slot.baked, page_highlights))) continue;
- // Invalidated, but it already shows exactly these highlights
- // (a preview withdrawn that was never drawn, on every pointer
- // move over a page): nothing to render.
- if (slot.baked_valid and sameHighlights(slot.baked, page_highlights)) {
- slot.tried = true;
- continue;
- }
- // Only the highlights moved: repaint their rows in place.
- if (repaintHighlights(state, gpa, slot, request, tint_key, page_highlights)) continue;
- }
-
- slot.tried = true;
- slot.request = request;
- slot.request_valid = true;
- const shape_or_null = shape: {
- const tz_measure = tracy.zone(@src(), "pdf.measure");
- defer tz_measure.end();
- break :shape state.document.measureRenderAt(page, request) catch null;
- };
- const shape = shape_or_null orelse continue;
- const want = wantedBand(state, viewport, page, shape, is_flinging);
- const fresh = state.claimRgba(gpa, want.len) orelse continue;
- const filled = filled: {
- {
- const tz_render = tracy.zone(@src(), "pdf.render_into");
- defer tz_render.end();
- // A highlighted page keeps its clean rows for repaints.
- const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0);
- state.document.renderIntoAtWithPaper(
- page,
- request,
- shape,
- want,
- if (keep_clean) &.{} else page_highlights,
- fresh,
- pardes.platform == .macos,
- ) catch break :filled false;
- if (slot.clean.len != 0 and (!keep_clean or slot.clean.len != fresh.len)) {
- gpa.free(slot.clean);
- slot.clean = &.{};
- }
- if (keep_clean) {
- if (slot.clean.len == 0) slot.clean = gpa.alloc(u8, fresh.len) catch &.{};
- if (slot.clean.len == fresh.len) @memcpy(slot.clean, fresh);
- state.document.paintHighlightsAt(page, request, shape, want, page_highlights, fresh) catch
- break :filled false;
- }
- }
- const tz_tint = tracy.zone(@src(), "pdf.tint");
- defer tz_tint.end();
- pdf.tintRgba(fresh, tint_key.mode, tint_key.colors) catch
- break :filled false;
- break :filled true;
- };
- if (!filled) {
- state.retireRgba(gpa, fresh);
- // `clean` may already hold the band that failed.
- if (slot.clean.len > 0) gpa.free(slot.clean);
- slot.clean = &.{};
- continue;
- }
-
- state.retireRgba(gpa, slot.rgba);
- slot.rgba = fresh;
- slot.iw = shape.width;
- slot.ih = shape.height;
- slot.band_y = want.y;
- slot.band_h = want.height;
- slot.decorated = decorated;
- slot.tint_key = tint_key;
- if (gpa.dupe(Highlight, page_highlights)) |copy| {
- gpa.free(slot.baked);
- slot.baked = copy;
- slot.baked_valid = true;
- } else |_| {
- // Unrecorded highlights cannot be compared or diffed.
- slot.baked_valid = false;
- if (slot.clean.len > 0) gpa.free(slot.clean);
- slot.clean = &.{};
- }
- slot.patch_from = 0;
- slot.patch_rows = .{ .start = 0, .end = 0 };
- state.next_raster_revision +%= 1;
- if (state.next_raster_revision == 0) state.next_raster_revision = 1;
- slot.revision = state.next_raster_revision;
- }
- state.trimSpares(gpa);
- }
-
- /// Repaint only the rows where the old and new highlights differ: the
- /// clean rows kept from the page's last render, MuPDF's highlight pass
- /// (the whole new set) over them, the per-pixel tint — the operations a
- /// full render performs, in its order, on fewer rows and without running
- /// the page again. False leaves the slot for the full path.
- fn repaintHighlights(
- state: *State,
- gpa: std.mem.Allocator,
- slot: *Raster,
- request: RenderRequest,
- tint_key: TintKey,
- page_highlights: []const Highlight,
- ) bool {
- const tz = tracy.zone(@src(), "pdf.repaint_highlights");
- defer tz.end();
- if (slot.clean.len != slot.rgba.len or !slot.baked_valid) return false;
- if (sameHighlights(slot.baked, page_highlights)) {
- slot.tried = true;
- return true;
- }
- const baked = gpa.dupe(Highlight, page_highlights) catch return false;
- const changed = changedHighlights(gpa, slot.baked, page_highlights) catch {
- gpa.free(baked);
- return false;
- };
- defer gpa.free(changed);
- var dirty = state.document.highlightRows(slot.page, request, changed) catch {
- gpa.free(baked);
- return false;
- };
- slot.tried = true;
- dirty.start = @max(dirty.start, slot.band_y);
- dirty.end = @min(dirty.end, slot.band_y + slot.band_h);
- if (dirty.start < dirty.end) {
- const shape = slotShape(slot);
- const band = shape.band(dirty.start, dirty.end - dirty.start);
- const offset = (dirty.start - slot.band_y) * shape.stride;
- const out = slot.rgba[offset..][0..band.len];
- @memcpy(out, slot.clean[offset..][0..band.len]);
- // On failure the rows are half-made: let the full path redo them.
- state.document.paintHighlightsAt(slot.page, request, shape, band, page_highlights, out) catch {
- gpa.free(baked);
- slot.tried = false;
- return false;
- };
- pdf.tintRgba(out, tint_key.mode, tint_key.colors) catch {
- gpa.free(baked);
- slot.tried = false;
- return false;
- };
- slot.patch_rows = unionRows(slot.patch_rows, dirty);
- state.next_raster_revision +%= 1;
- if (state.next_raster_revision == 0) state.next_raster_revision = 1;
- slot.revision = state.next_raster_revision;
- }
- gpa.free(slot.baked);
- slot.baked = baked;
- slot.decorated = page_highlights.len > 0;
- return true;
- }
-
- fn sameHighlights(a: []const Highlight, b: []const Highlight) bool {
- return std.mem.eql(u8, std.mem.sliceAsBytes(a), std.mem.sliceAsBytes(b));
- }
-
- /// The highlights in one set and not the other, as multisets (a quad
- /// painted twice blends twice). When only the order changed, blending
- /// order did, so everything counts.
- // ponytail: quadratic match; past 64k comparisons every quad counts.
- fn changedHighlights(gpa: std.mem.Allocator, old: []const Highlight, new: []const Highlight) ![]Highlight {
- const out = try gpa.alloc(Highlight, old.len + new.len);
- errdefer gpa.free(out);
- var n: usize = 0;
- if (old.len * new.len <= 1 << 16) {
- const matched = try gpa.alloc(bool, new.len);
- defer gpa.free(matched);
- @memset(matched, false);
- for (old) |h| {
- for (new, matched) |candidate, *used| {
- if (!used.* and std.mem.eql(u8, std.mem.asBytes(&h), std.mem.asBytes(&candidate))) {
- used.* = true;
- break;
- }
- } else {
- out[n] = h;
- n += 1;
- }
- }
- for (new, matched) |h, used| if (!used) {
- out[n] = h;
- n += 1;
- };
- }
- if (n == 0) {
- @memcpy(out[0..old.len], old);
- @memcpy(out[old.len..], new);
- return out;
- }
- // Freed by length: hand back exactly what the caller will free.
- return gpa.realloc(out, n);
- }
-
- fn unionRows(a: pdf.Document.Rows, b: pdf.Document.Rows) pdf.Document.Rows {
- if (a.start >= a.end) return b;
- if (b.start >= b.end) return a;
- return .{ .start = @min(a.start, b.start), .end = @max(a.end, b.end) };
- }
-
- pub fn renderFrame(
- state: *State,
- gpa: std.mem.Allocator,
- arena: std.mem.Allocator,
- viewport: Viewport,
- policy: RasterPolicy,
- tint_key: TintKey,
- highlight_input: HighlightInput,
- ) VisiblePages {
- if (comptime !enabled) return .{};
- ensureLayout(state, viewport);
- state.resolveSearch(gpa);
- const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{};
- const request = renderRequest(viewport, policy);
- var visible = visiblePages(state, viewport);
- reconcile(state, gpa, request, tint_key, highlights, visible, viewport);
-
- rearmSearchReveal(state, viewport);
- revealSearch(state, viewport, activeGeometry(state, viewport));
- visible = visiblePages(state, viewport);
- reconcile(state, gpa, request, tint_key, highlights, visible, viewport);
- return visible;
- }
-
- pub fn normalizedPixel(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))));
- }
-
- pub fn scaledStep(base: u32, count: u32) u32 {
- return @intCast(@min(
- @as(u64, std.math.maxInt(u32)),
- @as(u64, base) * @max(@as(u64, 1), count),
- ));
- }
-
- pub fn scrollDocument(state: *State, viewport: Viewport, delta_pixels: f64) ?ScrollResult {
- if (comptime !enabled) return null;
- if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return null;
- const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
- const next = std.math.clamp(state.document_scroll_y + delta_pixels, 0, max_scroll);
- if (next == state.document_scroll_y) return null;
- state.scroll_travel += @abs(next - state.document_scroll_y);
- state.document_scroll_y = next;
- return .{ .active_page = pageAtOffset(state, next) };
- }
-
- pub fn panPixels(
- state: *State,
- geometry: image.NativeGeometry,
- axis: PanAxis,
- direction: i8,
- display_pixels: u32,
- ) PanResult {
- if (comptime !enabled) return .unavailable;
- const raster = rasterForPage(state, state.page) orelse return .unavailable;
- const source_full: u32 = @intCast(switch (axis) {
- .horizontal => raster.iw,
- .vertical => raster.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 => &state.pan_x,
- .vertical => &state.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 setHorizontalEdge(state: *State, geometry: image.NativeGeometry, end: bool) void {
- if (comptime !enabled) return;
- const raster = rasterForPage(state, state.page) orelse return;
- if (raster.iw <= geometry.src.w) return;
- state.pan_x = if (end) std.math.maxInt(u16) else 0;
- }
-
- fn rearmSearchReveal(state: *State, viewport: Viewport) void {
- if (comptime !enabled) return;
- if (state.search_query.len == 0) return;
- if (!state.reveal_viewport_valid or
- state.reveal_viewport_w != viewport.pixel_w or
- state.reveal_viewport_h != viewport.pixel_h or
- state.reveal_fit != state.fit)
- state.search_reveal_pending = true;
- }
-
- pub fn revealSearch(
- state: *State,
- viewport: Viewport,
- geometry: ?image.NativeGeometry,
- ) void {
- if (comptime !enabled) return;
- if (!state.search_reveal_pending) return;
- state.reveal_viewport_w = viewport.pixel_w;
- state.reveal_viewport_h = viewport.pixel_h;
- state.reveal_fit = state.fit;
- state.reveal_viewport_valid = true;
- const results = state.search_results orelse {
- state.search_reveal_pending = false;
- return;
- };
- if (results.hit_count == 0 or results.quads.len == 0) {
- state.search_reveal_pending = false;
- return;
- }
- state.search_hit = @min(state.search_hit, results.hit_count - 1);
- const q = for (results.quads) |item| {
- if (item.hit == state.search_hit) break item.quad;
- } else {
- state.search_reveal_pending = false;
- return;
- };
- state.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;
- if (state.fit == .width) {
- ensureLayout(state, viewport);
- const page_y = @as(f64, @floatFromInt(state.page_starts[state.page])) +
- @as(f64, center_y) * @as(f64, @floatFromInt(state.page_heights[state.page]));
- const wanted = page_y - @as(f64, @floatFromInt(viewport.pixel_h)) / 2;
- const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
- state.document_scroll_y = std.math.clamp(wanted, 0, max_scroll);
- return;
- }
- const placed = geometry orelse return;
- const raster = rasterForPage(state, state.page) orelse return;
- const full: u32 = @intCast(raster.iw);
- const at = normalizedPixel(center_x, raster.iw);
- if (at >= placed.src.x and at < placed.src.x + placed.src.w) return;
- const overflow = full -| placed.src.w;
- if (overflow == 0) return;
- const wanted = @min(overflow, at -| placed.src.w / 2);
- state.pan_x = @intCast(
- (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
- );
- }
-
- pub fn stepPage(state: *State, gpa: std.mem.Allocator, delta: i64) bool {
- const current: i64 = @intCast(state.page);
- const last: i64 = @intCast(state.page_count -| 1);
- return state.activatePage(
- gpa,
- @intCast(std.math.clamp(current + delta, 0, last)),
- true,
- );
- }
-
- fn scrollNormal(
- state: *State,
- gpa: std.mem.Allocator,
- viewport: ?Viewport,
- delta_pixels: f64,
- ) bool {
- const view = viewport orelse return false;
- ensureLayout(state, view);
- const result = scrollDocument(state, view, delta_pixels) orelse return false;
- return result.active_page != state.page and
- state.activatePage(gpa, result.active_page, false);
- }
-
- fn moveRows(
- state: *State,
- gpa: std.mem.Allocator,
- native_images: bool,
- viewport: ?Viewport,
- cell_pixels: CellPixels,
- direction: i8,
- count: u32,
- ) bool {
- if (!native_images) {
- const pages: i64 = @intCast(@max(@as(u32, 1), count));
- return stepPage(state, gpa, if (direction > 0) pages else -pages);
- }
- return scrollNormal(
- state,
- gpa,
- viewport,
- @as(f64, @floatFromInt(scaledStep(cell_pixels.h, count))) * direction,
- );
- }
-
- fn movePage(
- state: *State,
- gpa: std.mem.Allocator,
- native_images: bool,
- viewport: ?Viewport,
- cell_pixels: CellPixels,
- direction: i8,
- kind: modal.Normal.Page,
- count: u32,
- ) bool {
- if (!native_images) {
- const pages: i64 = @intCast(@max(@as(u32, 1), count));
- return stepPage(state, gpa, if (direction > 0) pages else -pages);
- }
- const base: u32 = switch (kind) {
- .half_down, .half_up => if (viewport) |view|
- @max(@as(u32, 1), view.pixel_h / 2)
- else
- cell_pixels.h,
- .down, .up => if (viewport) |view| view.pixel_h else cell_pixels.h,
- };
- return scrollNormal(
- state,
- gpa,
- viewport,
- @as(f64, @floatFromInt(scaledStep(base, count))) * direction,
- );
- }
-
- pub fn applyNormal(
- state: *State,
- gpa: std.mem.Allocator,
- semantic: modal.Normal.Action,
- native_images: bool,
- cell_pixels: CellPixels,
- viewport: ?Viewport,
- geometry: ?image.NativeGeometry,
- ) NormalResult {
- if (comptime !enabled) return .{};
- var result: NormalResult = .{};
- switch (semantic) {
- .escape => state.cancelChrome(gpa),
- .move => |move| switch (move.motion) {
- .down => result.page_changed = moveRows(
- state,
- gpa,
- native_images,
- viewport,
- cell_pixels,
- 1,
- move.count,
- ),
- .up => result.page_changed = moveRows(
- state,
- gpa,
- native_images,
- viewport,
- cell_pixels,
- -1,
- move.count,
- ),
- .left => if (native_images) if (geometry) |placed| {
- _ = panPixels(state, placed, .horizontal, -1, scaledStep(cell_pixels.w, move.count));
- },
- .right => if (native_images) if (geometry) |placed| {
- _ = panPixels(state, placed, .horizontal, 1, scaledStep(cell_pixels.w, move.count));
- },
- else => {},
- },
- .goto => |go| switch (go.target) {
- .file_start => result.page_changed = state.activatePage(
- gpa,
- if (go.explicit_count) go.count -| 1 else 0,
- true,
- ),
- .last_line => result.page_changed = state.activatePage(gpa, state.page_count -| 1, true),
- .line_start, .first_nonws => if (native_images) if (geometry) |placed|
- setHorizontalEdge(state, placed, false),
- .line_end => if (native_images) if (geometry) |placed|
- setHorizontalEdge(state, placed, true),
- .line_down => result.page_changed = moveRows(
- state,
- gpa,
- native_images,
- viewport,
- cell_pixels,
- 1,
- go.count,
- ),
- .line_up => result.page_changed = moveRows(
- state,
- gpa,
- native_images,
- viewport,
- cell_pixels,
- -1,
- go.count,
- ),
- else => {},
- },
- .line => |line| switch (line) {
- .start, .first_nonws => if (native_images) if (geometry) |placed|
- setHorizontalEdge(state, placed, false),
- .end => if (native_images) if (geometry) |placed|
- setHorizontalEdge(state, placed, true),
- },
- .goto_line => |go| {
- if (go.explicit)
- result.page_changed = state.activatePage(gpa, go.count -| 1, true);
- },
- .page => |page| result.page_changed = switch (page.kind) {
- .half_down, .down => movePage(
- state,
- gpa,
- native_images,
- viewport,
- cell_pixels,
- 1,
- page.kind,
- page.count,
- ),
- .half_up, .up => movePage(
- state,
- gpa,
- native_images,
- viewport,
- cell_pixels,
- -1,
- page.kind,
- page.count,
- ),
- },
- .view => |view| result.page_changed = switch (view) {
- .scroll_down => moveRows(state, gpa, native_images, viewport, cell_pixels, 1, 1),
- .scroll_up => moveRows(state, gpa, native_images, viewport, cell_pixels, -1, 1),
- else => false,
- },
- .leader => result.host = .leader,
- .command_line => result.host = .command_line,
- .search => result.host = .search,
- .search_step => |direction| result.host = if (direction == .forward)
- .search_forward
- else
- .search_backward,
- else => {},
- }
- return result;
- }
-
- pub fn captureLayoutAnchor(state: *State) void {
- if (comptime !enabled) return;
- if (!state.layout_valid or state.page_count == 0 or state.document_height == 0) return;
- const page = pageAtOffset(state, state.document_scroll_y);
- const start: f64 = @floatFromInt(state.page_starts[page]);
- const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[page]));
- state.layout_anchor_page = page;
- state.layout_anchor_fraction = std.math.clamp((state.document_scroll_y - start) / height, 0, 1);
- state.layout_anchor_pending = true;
- }
-
- fn consumeOutlineReveal(state: *State, viewport: Viewport) void {
- const destination = state.outline_reveal_pending orelse return;
- state.outline_reveal_pending = null;
- const page = @min(destination.page, state.page_count -| 1);
- const size = state.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;
- state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) +
- @as(f64, y) / @as(f64, size.height) * @as(f64, @floatFromInt(state.page_heights[page]));
-
- if (destination.x) |raw_x| if (state.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);
- state.pan_x = @intFromFloat(@round(
- wanted / overflow * @as(f64, std.math.maxInt(u16)),
- ));
- }
- };
- state.search_reveal_pending = false;
- state.reveal_viewport_w = viewport.pixel_w;
- state.reveal_viewport_h = viewport.pixel_h;
- state.reveal_fit = state.fit;
- state.reveal_viewport_valid = true;
- }
-
- pub fn ensureLayout(state: *State, viewport: Viewport) void {
- if (comptime !enabled) return;
- const tz = tracy.zone(@src(), "pdf.ensure_layout");
- defer tz.end();
- if (state.layout_valid and !state.scroll_to_page_pending and
- !state.layout_anchor_pending and
- (state.layout_viewport_w != viewport.pixel_w or
- state.layout_viewport_h != viewport.pixel_h)) captureLayoutAnchor(state);
- if (!state.layout_valid or state.layout_viewport_w != viewport.pixel_w or
- state.layout_viewport_h != viewport.pixel_h or state.layout_fit != state.fit)
- {
- var at: u64 = 0;
- for (state.page_sizes, 0..) |size, page| {
- state.page_starts[page] = at;
- const height = pageHeight(size, viewport, state.fit);
- state.page_heights[page] = height;
- at = std.math.add(u64, at, height) catch std.math.maxInt(u64);
- if (page + 1 < state.page_count)
- at = std.math.add(u64, at, page_gap_px) catch std.math.maxInt(u64);
- }
- state.document_height = at;
- state.layout_viewport_w = viewport.pixel_w;
- state.layout_viewport_h = viewport.pixel_h;
- state.layout_fit = state.fit;
- state.layout_valid = true;
- if (state.scroll_to_page_pending) {
- state.document_scroll_y = @floatFromInt(state.page_starts[state.page]);
- state.scroll_to_page_pending = false;
- state.layout_anchor_pending = false;
- } else if (state.layout_anchor_pending) {
- const page = @min(state.layout_anchor_page, state.page_count -| 1);
- state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) +
- state.layout_anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page]));
- state.layout_anchor_pending = false;
- }
- }
- consumeOutlineReveal(state, viewport);
- const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
- state.document_scroll_y = std.math.clamp(state.document_scroll_y, 0, max_scroll);
- }
-
- pub const WordProbe = if (enabled) struct {
- page: usize,
- quads: []pdf.Quad,
- text: []u8,
- link: ?pdf.Link = null,
-
- pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void {
- if (probe.link) |*link| link.deinit(gpa);
- gpa.free(probe.quads);
- gpa.free(probe.text);
- probe.* = undefined;
- }
- } else void;
-
- pub const PointerDrag = if (enabled) struct {
- native: bool = false,
- /// Right-button Look probes this cell on release without borrowing or
- /// replacing the pane's persistent MuPDF selection.
- word_at: ?struct { col: u16, row: u16 } = null,
- /// Drag updates keep selection geometry/text live, but their expensive
- /// baked raster highlight is committed once on release.
- selection_changed: bool = false,
- } else struct {};
-
- pub const PointerAction = enum { look, exec };
-
- pub const PointerRelease = if (enabled) struct {
- action: ?PointerAction = null,
- text: []const u8 = &.{},
- probe: ?WordProbe = null,
-
- pub fn deinit(release: *@This(), gpa: std.mem.Allocator) void {
- if (release.probe) |*probe| probe.deinit(gpa);
- release.* = undefined;
- }
- } else struct {
- pub fn deinit(_: *@This(), _: std.mem.Allocator) void {}
- };
-
- pub fn probeWord(
- document: *Document,
- gpa: std.mem.Allocator,
- page: usize,
- point: if (enabled) pdf.Point else void,
- ) !?WordProbe {
- if (comptime !enabled) return null;
- var selection = try document.select(gpa, page, point, point);
- errdefer selection.deinit(gpa);
- const text = try document.copySelection(gpa, page, selection.start, selection.end);
- errdefer gpa.free(text);
- if (selection.quads.len == 0 or text.len == 0) {
- selection.deinit(gpa);
- gpa.free(text);
- return null;
- }
- // Transfer the quad allocation out of Selection. There is intentionally
- // no Selection.deinit on this success path: WordProbe is now its owner.
- return .{ .page = page, .quads = selection.quads, .text = text };
- }
-
- pub fn pointAtGeometry(
- geometry: image.NativeGeometry,
- pixel_offset_y: f32,
- full_width: usize,
- full_height: usize,
- band_y: usize,
- pixel_x: i64,
- pixel_y: i64,
- clamp_to_page: bool,
- ) ?Point {
- if (comptime !enabled) return null;
- if (full_width == 0 or full_height == 0 or
- geometry.src.w == 0 or geometry.src.h == 0 or
- geometry.dst.w == 0 or geometry.dst.h == 0) return null;
-
- const left: f64 = @floatFromInt(geometry.dst.x);
- const top: f64 = @floatFromInt(geometry.dst.y);
- const right = left + @as(f64, @floatFromInt(geometry.dst.w));
- const bottom = top + @as(f64, @floatFromInt(geometry.dst.h));
- var x: f64 = @floatFromInt(pixel_x);
- var y: f64 = @as(f64, @floatFromInt(pixel_y)) - @as(f64, pixel_offset_y);
- if (clamp_to_page) {
- // Half-open rectangles: keep a clamped point infinitesimally inside
- // the last presented pixel instead of letting it become `right`.
- const epsilon = 1.0 / 1024.0;
- x = std.math.clamp(x, left, @max(left, right - epsilon));
- y = std.math.clamp(y, top, @max(top, bottom - epsilon));
- } else if (x < left or x >= right or y < top or y >= bottom) {
- return null;
- }
-
- const source_x_f = @as(f64, @floatFromInt(geometry.src.x)) +
- (x - left) * @as(f64, @floatFromInt(geometry.src.w)) /
- @as(f64, @floatFromInt(geometry.dst.w));
- const source_y_f = @as(f64, @floatFromInt(geometry.src.y)) +
- (y - top) * @as(f64, @floatFromInt(geometry.src.h)) /
- @as(f64, @floatFromInt(geometry.dst.h));
- const source_x: usize = @min(full_width - 1, @as(usize, @intFromFloat(@floor(source_x_f))));
- const band_source_y: usize = @intFromFloat(@floor(source_y_f));
- const source_y = @min(full_height - 1, band_y + band_source_y);
- return .{
- .x = (@as(f32, @floatFromInt(source_x)) + 0.5) /
- @as(f32, @floatFromInt(full_width)),
- .y = (@as(f32, @floatFromInt(source_y)) + 0.5) /
- @as(f32, @floatFromInt(full_height)),
- };
- }
-
- pub fn openPane(
- core: *pardes.Pardes,
- id: usize,
- path: []const u8,
- page_one_based: usize,
- ) !*pardes.Pane {
- if (comptime !enabled) return error.PdfDisabled;
- // The slot stays ours across the read: another request may make a
- // pane meanwhile.
- core.reserved_slots[id] = true;
- defer core.reserved_slots[id] = false;
- var state = if (filesystem.localPath(path) != null)
- try State.open(core.pdf_gpa, path, page_one_based)
- else virtual: {
- const bytes = try filesystem.read(core, path);
- defer core.gpa.free(bytes);
- break :virtual try State.openBytes(core.pdf_gpa, path, bytes, page_one_based);
- };
- errdefer state.deinit(core.pdf_gpa);
- const pane = try core.newDocPane(id);
- pane.pdf = state;
- pane.cur_pinned = true;
- if (filesystem.localPath(path) != null) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
- return pane;
- }
-
- /// Release a PDF payload while its pane slot is still installed, so the host
- /// can retire the corresponding directory watch before that id is reused.
- pub fn deinitPane(core: *pardes.Pardes, pane: *pardes.Pane, state: *State) void {
- if (comptime !enabled) return;
- for (core.panes, 0..) |slot, id| {
- if (slot == pane) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
- }
- state.deinit(core.pdf_gpa);
- }
-
- pub fn reloadPane(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- ) !void {
- if (comptime !enabled) return error.PdfDisabled;
- const state = &(pane.pdf orelse return error.MissingPdfState);
- try state.reload(core.pdf_gpa);
- resetPageChrome(pane);
- }
-
- pub fn isSectionsOutput(pane: *const pardes.Pane) bool {
- if (comptime !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,
- };
- }
-
- fn refreshCleanSections(core: *pardes.Pardes, state: *State) void {
- if (comptime !enabled) return;
- const remembered = state.sections_output orelse return;
- if (remembered.pane >= core.panes.len) return;
- const result = core.panes[remembered.pane] orelse return;
- if (result.serial != remembered.serial or !isSectionsOutput(result)) return;
- const file = &result.file.?;
- if (file.revision != remembered.revision) return;
-
- const content = state.renderSections(core.pdf_gpa, core.gpa) catch {
- state.sections_output = null;
- return;
- };
- if (std.mem.eql(u8, file.content, content)) {
- core.gpa.free(content);
- return;
- }
- core.invalidateLookHover(remembered.pane);
- File.setContent(core, file, content);
- const rows = File.lineCount(file.content);
- file.scroll = @min(file.scroll, rows - 1);
- const row = @min(@as(usize, @intCast(@max(0, result.cur_row))), rows - 1);
- result.cur_row = @intCast(row);
- result.cur_col = @intCast(@min(
- @as(usize, @intCast(@max(0, result.cur_col))),
- modal.lineSlice(file.content, row).len,
- ));
- result.msel.active = false;
- result.vsel.active = false;
- result.nsel = 0;
- state.sections_output.?.revision = file.revision;
- }
-
- /// One successful watched-path transaction, including every piece of derived
- /// PDF output. The caller owns generic update bookkeeping and status text.
- pub fn reloadWatched(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- ) !void {
- try reloadPane(core, pane);
- refreshCleanSections(core, &pane.pdf.?);
- }
-
- fn rearmCleanSections(
- core: *pardes.Pardes,
- id: usize,
- pane: *pardes.Pane,
- state: *State,
- ) bool {
- const remembered = state.sections_output orelse return false;
- if (remembered.pane >= core.panes.len) return false;
- const result = core.panes[remembered.pane] orelse return false;
- if (result.serial != remembered.serial or !isSectionsOutput(result)) return false;
- const file = &result.file.?;
- if (file.revision != remembered.revision) return false;
-
- 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;
- core.armLookWalk(remembered.pane);
- core.active = id;
- return true;
- }
-
- /// Lazily materialise this pane's cached outline as a location list. All
- /// PDF-specific ownership stays here; Pardes contributes only placement.
- pub fn openSections(core: *pardes.Pardes, id: usize) void {
- if (comptime !enabled) return;
- const pane = core.panes[id] orelse return;
- const state = &(pane.pdf orelse return);
- if (rearmCleanSections(core, id, pane, state)) return;
- const content = state.renderSections(core.pdf_gpa, core.gpa) catch return;
-
- const free = core.freeSlot() orelse {
- core.gpa.free(content);
- return;
- };
- const dir = std.fs.path.dirname(state.path) orelse "/";
- const result = Output.open(
- core,
- free,
- dir,
- .{ .cmd = .PdfSections },
- "",
- content,
- ) catch {
- core.gpa.free(content);
- return;
- };
- state.sections_output = .{
- .pane = free,
- .serial = result.serial,
- .revision = result.file.?.revision,
- };
- core.placeDoc(id, free, result);
- layout.compute(core);
- core.active = id;
- pane.search_pane = free;
- pane.search_row = null;
- core.armLookWalk(free);
- }
-
- const SectionsOwner = struct {
- id: usize,
- pane: *pardes.Pane,
- state: *State,
- };
-
- fn sectionsOwner(core: *pardes.Pardes, output_id: usize) ?SectionsOwner {
- const output = core.panes[output_id] orelse return null;
- if (!isSectionsOutput(output)) return null;
- const file = output.file.?;
- for (core.panes, 0..) |slot, id| {
- const pane = slot orelse continue;
- const state = if (pane.pdf) |*pdf_state| pdf_state else continue;
- const token = state.sections_output orelse continue;
- if (token.pane == output_id and token.serial == output.serial and
- token.revision == file.revision)
- return .{ .id = id, .pane = pane, .state = state };
- }
- return null;
- }
-
- pub fn lookSection(
- core: *pardes.Pardes,
- output_id: usize,
- path: []const u8,
- at: look.Spot,
- ) bool {
- if (comptime !enabled) return false;
- const output = core.panes[output_id] orelse return false;
- if (!isSectionsOutput(output)) return false;
- if (at.line == 0 or at.col == 0 or at.end_line != 0 or
- !look.isPdfPath(path)) return false;
- const owner = sectionsOwner(core, output_id) orelse return true;
-
- var joined_path: [4096]u8 = undefined;
- const output_dir = std.fs.path.dirname(output.file.?.path) orelse "/";
- const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/";
- const target_path = if (std.fs.path.isAbsolute(path))
- path
- else
- std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true;
- if (!std.mem.eql(u8, owner.state.path, target_path)) return true;
-
- const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true;
- switch (destination) {
- .internal => |internal| {
- core.clearNavigationSelection(owner.pane);
- revealOutlineDestination(core, owner.pane, internal);
- },
- .external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }),
- .none => unreachable,
- }
- core.active = owner.id;
- return true;
- }
-
- pub fn resetPageChrome(pane: *pardes.Pane) void {
- pane.cur_row = 0;
- pane.cur_col = 0;
- pane.vsel.active = false;
- pane.msel.active = false;
- pane.nsel = 0;
- }
-
- pub fn activatePage(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- page: usize,
- reveal: bool,
- ) void {
- if (comptime !enabled) return;
- const state = &(pane.pdf orelse return);
- if (state.activatePage(core.pdf_gpa, page, reveal)) resetPageChrome(pane);
- }
-
- pub fn revealOutlineDestination(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- destination: OutlineInternalDestination,
- ) void {
- if (comptime !enabled) return;
- activatePage(core, pane, destination.page, false);
- const state = &pane.pdf.?;
- state.queueOutlineReveal(destination);
- // Consume immediately when geometry already exists; otherwise the PDF
- // draw pass consumes the same value after the next layout transaction.
- _ = ensurePaneLayout(core, pane);
- }
-
- pub fn setPage(core: *pardes.Pardes, pane: *pardes.Pane, page: usize) void {
- activatePage(core, pane, page, true);
- }
-
- pub fn toggleFit(pane: *pardes.Pane) void {
- if (comptime !enabled) return;
- const state = &(pane.pdf orelse return);
- state.toggleFit();
- }
-
- pub fn toggleTint(pane: *pardes.Pane) void {
- if (comptime !enabled) return;
- const state = &(pane.pdf orelse return);
- state.toggleTint();
- }
-
- pub fn stepPanePage(core: *pardes.Pardes, pane: *pardes.Pane, delta: i64) void {
- if (comptime !enabled) return;
- const state = &(pane.pdf orelse return);
- if (stepPage(state, core.pdf_gpa, delta)) resetPageChrome(pane);
- }
-
- pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewport {
- if (comptime !enabled) return null;
- const rect = for (core.panes, 0..) |slot, id| {
- if (slot == pane) break core.rects[id];
- } else return null;
- const cols = rect.w -| config.GUTTER;
- const rows = rect.h -| pardes.BOX_H;
- if (cols == 0 or rows == 0) return null;
- return .{
- .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w),
- .pixel_h = @as(u32, rows) * @as(u32, core.cell_pixels.h),
- };
- }
-
- pub fn ensurePaneLayout(core: *pardes.Pardes, pane: *pardes.Pane) ?Viewport {
- if (comptime !enabled) return null;
- const state = &(pane.pdf orelse return null);
- const view = paneViewport(core, pane) orelse return null;
- ensureLayout(state, view);
- return view;
- }
-
- pub fn tintColors(core: *const pardes.Pardes) TintColors {
- if (comptime !enabled) return;
- const theme = core.theme();
- return .{
- .background = theme.bg orelse theme.tag_bg,
- .foreground = theme.fg orelse theme.tag_fg,
- };
- }
-
- pub fn paneGeometry(core: *const pardes.Pardes, pane: *const pardes.Pane) ?image.NativeGeometry {
- if (comptime !enabled) return null;
- const state = if (pane.pdf) |*view| view else return null;
- const view = paneViewport(core, pane) orelse return null;
- return activeGeometry(state, view);
- }
-
- pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: u16) ?usize {
- if (comptime !enabled) return null;
- const state = pane.pdf orelse return null;
- const rect = for (core.panes, 0..) |slot, id| {
- if (slot == pane) break core.rects[id];
- } else return null;
- const body_y = core.bodyTop(rect);
- if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null;
- const local_y = @as(f64, @floatFromInt(
- @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2,
- ));
- return pageAtViewportY(&state, local_y);
- }
-
- pub fn paneNativeReady(core: *const pardes.Pardes, pane: *const pardes.Pane) bool {
- if (comptime !enabled) return false;
- if (!core.native_images) return false;
- const state = if (pane.pdf) |*view| view else return false;
- const view = paneViewport(core, pane) orelse return false;
- return nativeReady(state, view);
- }
-
- pub fn nativePageAtGridRow(
- core: *const pardes.Pardes,
- pane: *const pardes.Pane,
- row: u16,
- ) ?usize {
- if (comptime !enabled) return null;
- if (!core.native_images) return null;
- const state = if (pane.pdf) |*view| view else return null;
- const page = pageAtGridRow(core, pane, row) orelse return null;
- const view = paneViewport(core, pane) orelse return null;
- if (!pageReady(state, view, page)) return null;
- return page;
- }
-
- pub fn pointAt(
- core: *const pardes.Pardes,
- pane: *const pardes.Pane,
- col: u16,
- row: u16,
- clamp_to_page: bool,
- ) ?Point {
- if (comptime !enabled) return null;
- const state = pane.pdf orelse return null;
- return panePointAtPage(core, pane, state.page, col, row, clamp_to_page);
- }
-
- pub fn panePointAtPage(
- core: *const pardes.Pardes,
- pane: *const pardes.Pane,
- page: usize,
- col: u16,
- row: u16,
- clamp_to_page: bool,
- ) ?Point {
- if (comptime !enabled) return null;
- const state = if (pane.pdf) |*view| view else return null;
- const view = paneViewport(core, pane) orelse return null;
- const rect = for (core.panes, 0..) |slot, id| {
- if (slot == pane) break core.rects[id];
- } else return null;
- const body_x = @as(i64, rect.x + config.GUTTER);
- const body_y = @as(i64, core.bodyTop(rect));
- const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2;
- const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2;
- return pointAtPage(state, view, page, px, py, clamp_to_page);
- }
-
- pub fn probeAt(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- col: u16,
- row: u16,
- ) ?WordProbe {
- if (comptime !enabled) return null;
- const page = nativePageAtGridRow(core, pane, row) orelse return null;
- const point = panePointAtPage(core, pane, page, col, row, false) orelse return null;
- const state = &(pane.pdf orelse return null);
- if (state.document.linkAt(core.pdf_gpa, page, point) catch null) |found| {
- var link = found;
- if (core.pdfLinkLocation(pane, link) != null) {
- const quads = core.pdf_gpa.alloc(pdf.Quad, 1) catch {
- link.deinit(core.pdf_gpa);
- return null;
- };
- const text = state.document.copyRectangle(core.pdf_gpa, page, link.quad) catch {
- core.pdf_gpa.free(quads);
- link.deinit(core.pdf_gpa);
- return null;
- };
- quads[0] = link.quad;
- return .{ .page = page, .quads = quads, .text = text, .link = link };
- }
- link.deinit(core.pdf_gpa);
- }
- return probeWord(&state.document, core.pdf_gpa, page, point) catch null;
- }
-
- pub fn beginDrag(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- col: u16,
- row: u16,
- snap_word: bool,
- ) bool {
- if (comptime !enabled) return false;
- const hit_page = pageAtGridRow(core, pane, row) orelse return false;
- if (hit_page != pane.pdf.?.page) activatePage(core, pane, hit_page, false);
- const state = &pane.pdf.?;
- state.clearDrag();
- const point = pointAt(core, pane, col, row, false) orelse return false;
- state.drag_anchor = point;
- state.drag_head = point;
- if (!snap_word) return true;
- if (state.selection_page == state.page) if (state.selection) |selection| if (selection.contains(point)) return true;
- if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) {
- // Preserve the old selection transactionally, but never let an
- // outside click execute its stale text.
- state.clearDrag();
- return false;
- }
- return true;
- }
-
- pub fn beginSelection(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- col: u16,
- row: u16,
- ) bool {
- return beginDrag(core, pane, col, row, true);
- }
-
- pub fn updateSelection(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- col: u16,
- row: u16,
- invalidate_raster: bool,
- ) SelectionUpdate {
- if (comptime !enabled) return .failed;
- const state = &(pane.pdf orelse return .failed);
- const anchor = state.drag_anchor orelse return .failed;
- const point = pointAt(core, pane, col, row, true) orelse return .failed;
- if (state.drag_head) |head| if (head.x == point.x and head.y == point.y) return .stationary;
- const result = state.setSelection(core.pdf_gpa, anchor, point, invalidate_raster);
- if (result != .failed) state.drag_head = point;
- return result;
- }
-
- pub fn pointerStart(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- button: pardes.Mouse.Button,
- col: u16,
- row: u16,
- on_tag: bool,
- ) PointerDrag {
- if (comptime !enabled) return .{};
- if (on_tag or !paneNativeReady(core, pane)) return .{};
- var drag: PointerDrag = .{ .native = true };
- if (button == config.look_button) {
- drag.word_at = .{ .col = col, .row = row };
- } else if (button == config.select_button) {
- _ = beginDrag(core, pane, col, row, false);
- } else if (button == config.exec_button) {
- _ = beginDrag(core, pane, col, row, true);
- }
- return drag;
- }
-
- pub fn pointerUpdate(
- drag: *PointerDrag,
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- col: u16,
- row: u16,
- ) void {
- if (comptime !enabled) return;
- if (!drag.native) return;
- if (drag.word_at) |at| {
- if (col == at.col and row == at.row) return;
- if (!beginDrag(core, pane, at.col, at.row, false)) {
- drag.word_at = null;
- return;
- }
- switch (updateSelection(core, pane, col, row, false)) {
- .failed => {
- drag.word_at = null;
- pane.pdf.?.clearDrag();
- },
- // Adjacent grid cells can still address one raster pixel. Keep
- // click mode and retry farther out.
- .stationary => {},
- .unchanged => drag.word_at = null,
- .changed => {
- drag.word_at = null;
- drag.selection_changed = true;
- },
- }
- return;
- }
- switch (updateSelection(core, pane, col, row, false)) {
- .failed => pane.pdf.?.clearDrag(),
- .stationary, .unchanged => {},
- .changed => drag.selection_changed = true,
- }
- }
-
- pub fn pointerCancel(pane: *pardes.Pane, drag: PointerDrag) void {
- if (comptime !enabled) return;
- if (drag.native) if (pane.pdf) |*state| {
- if (drag.selection_changed) state.invalidateRaster(state.page);
- state.clearDrag();
- };
- }
-
- pub fn pointerRelease(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- drag: PointerDrag,
- button: pardes.Mouse.Button,
- chorded: bool,
- ) PointerRelease {
- if (comptime !enabled) return .{};
- const state = &(pane.pdf orelse return .{});
- const slot = @intFromEnum(button);
- const grid_selection = pane.sel[slot];
- pane.sel[slot].state = .none; // native quads, not projected text cells
-
- // Drag updates changed live geometry/text while leaving baked pixels
- // stable. Commit exactly once before any chord/Exec/Look can clear state.
- if (drag.selection_changed) state.invalidateRaster(state.page);
- if (chorded) {
- state.clearDrag();
- return .{};
- }
- if (drag.word_at) |at| {
- const maybe_probe = probeAt(core, pane, at.col, at.row);
- state.clearDrag();
- const probe = maybe_probe orelse return .{};
- // A neighboring visible page becomes current before Look dispatch.
- // WordProbe owns its text/quads independently of that state change.
- if (probe.page != state.page) activatePage(core, pane, probe.page, false);
- const text = probe.text;
- return .{ .action = .look, .text = text, .probe = probe };
- }
- if (button == config.select_button) {
- const dragged = grid_selection.c0 != grid_selection.c1 or
- grid_selection.r0 != grid_selection.r1;
- if (!dragged) state.clearSelection(core.pdf_gpa);
- pane.cur_pinned = true;
- pane.mode = .normal;
- pane.msel.active = false;
- pane.vsel.active = false;
- pane.normal.clear();
- pane.nsel = 0;
- state.clearDrag();
- return .{};
- }
- if (state.drag_anchor == null or state.selection_text.len == 0) {
- state.clearDrag();
- return .{};
- }
- const text = state.selection_text;
- state.clearDrag();
- return .{
- .action = if (button == config.look_button) .look else .exec,
- .text = text,
- };
- }
-
- pub fn highlightInput(
- core: *pardes.Pardes,
- pane_id: usize,
- pane: *const pardes.Pane,
- ) HighlightInput {
- if (comptime !enabled) return;
- const hover_quads: []const Quad = if (core.pdf_hover_preview) |preview|
- if (preview.pane == pane_id and preview.serial == pane.serial)
- preview.probe.quads
- else
- &.{}
- else
- &.{};
- const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null;
- const link_hover = hover_quads.len > 0 and core.pdf_hover_preview.?.probe.link != null;
- const selection_color = core.theme().sel_bg;
- return .{
- .hover_quads = hover_quads,
- .hover_page = hover_page,
- .hover_color = if (link_hover) core.theme().box else selection_color,
- .selection_color = selection_color,
- .live = core.native_image_patches,
- };
- }
-
- pub fn panPane(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- axis: PanAxis,
- direction: i8,
- display_pixels: u32,
- ) PanResult {
- if (comptime !enabled) return .unavailable;
- const placed = paneGeometry(core, pane) orelse return .unavailable;
- const state = &(pane.pdf orelse return .unavailable);
- return panPixels(state, placed, axis, direction, display_pixels);
- }
-
- pub fn scrollPane(core: *pardes.Pardes, pane: *pardes.Pane, delta_pixels: f64) bool {
- if (comptime !enabled) return false;
- const tz = tracy.zone(@src(), "pdf.scroll_notch");
- defer tz.end();
- const state = &(pane.pdf orelse return false);
- const view = ensurePaneLayout(core, pane) orelse return false;
- const result = scrollDocument(state, view, delta_pixels) orelse return false;
- if (result.active_page != state.page) activatePage(core, pane, result.active_page, false);
- return true;
- }
-
- pub fn verticalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void {
- if (comptime !enabled) return;
- if (!core.native_images) return stepPanePage(core, pane, direction);
- const rows: u32 = @intCast(@max(1, config.wheel_rows));
- const pixels = scaledStep(core.cell_pixels.h, rows);
- _ = scrollPane(core, pane, @as(f64, @floatFromInt(pixels)) * direction);
- }
-
- pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void {
- if (comptime !enabled) return;
- const state = pane.pdf orelse return;
- if (!core.native_images or state.fit != .height) return;
- const cols: u32 = @intCast(@max(1, config.wheel_cols));
- _ = panPane(core, pane, .horizontal, direction, scaledStep(core.cell_pixels.w, cols));
- }
-
- pub fn draw(
- core: *pardes.Pardes,
- pane: *pardes.Pane,
- rect: pardes.Rect,
- pane_id: usize,
- text_x: u16,
- text_width: u16,
- ) bool {
- if (comptime !enabled) return false;
- if (!core.native_images or rect.h <= pardes.BOX_H) return false;
- const state = &(pane.pdf orelse return false);
- const view = paneViewport(core, pane) orelse return false;
- const key = TintKey{ .mode = state.tint, .colors = tintColors(core) };
- const visible = renderFrame(
- state,
- core.pdf_gpa,
- core.scratch.allocator(),
- view,
- pardes.pdf_raster_policy,
- key,
- highlightInput(core, pane_id, pane),
- );
- var placed_any = false;
- var page = visible.first;
- const visible_end = visible.first + visible.len;
- while (page < visible_end) : (page += 1) {
- const placed = placedRaster(state, view, page) orelse continue;
- const slot = rasterForPage(state, page).?;
- const patch: ?pardes.ImagePatch = if (slot.patch_from != 0 and
- slot.patch_rows.start < slot.patch_rows.end)
- .{
- .from = slot.patch_from,
- .y = @intCast(slot.patch_rows.start - slot.band_y),
- .h = @intCast(slot.patch_rows.end - slot.patch_rows.start),
- }
- else
- null;
- if (!core.appendImagePlace(.{
- .pane = @intCast(pane_id),
- .serial = pane.serial,
- .native = .{
- .revision = placed.revision,
- .page = @intCast(placed.page),
- .fit = switch (placed.fit) {
- .width => .width,
- .height => .height,
- },
- .pan_x = placed.pan_x,
- .geometry = placed.geometry,
- .pixel_offset_y = placed.pixel_offset_y,
- .paper_bg = if (pardes.platform == .macos)
- (if (key.mode == .disabled) .{ 255, 255, 255 } else key.colors.background)
- else
- null,
- },
- .x = text_x,
- // Below the notice chips, like an image: a placed page is
- // drawn after the cells and would paint a chip out.
- .y = core.bodyTop(rect) + pane.notices.span(),
- .w = text_width,
- .h = (rect.h -| pardes.BOX_H) -| pane.notices.span(),
- .rgba = placed.rgba,
- .iw = placed.width,
- // The texture contains the retained band, not the full page.
- .ih = placed.band_height,
- .patch = patch,
- })) break;
- // A shell now holds (or will fetch whole) this revision.
- slot.patch_from = slot.revision;
- slot.patch_rows = .{ .start = 0, .end = 0 };
- placed_any = true;
- }
- if (!placed_any) return false;
-
- // This frame has spent the motion used to choose its raster band.
- state.scroll_travel = 0;
- const body_y = core.bodyTop(rect);
- const body_h = rect.h -| pardes.BOX_H;
- const chrome = core.chromeTheme();
- const theme = core.theme();
- const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default;
- // The rail runs past the notice bands; only the thumb tracks the text.
- const rail_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
- core.surface.fill(rect.x, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- core.surface.fill(rect.x + 1, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg });
-
- const track_h: usize = body_h;
- const total = @max(@as(u64, 1), state.document_height);
- const len = @max(
- @as(usize, 1),
- @as(usize, @intCast(@min(
- @as(u64, track_h),
- @as(u64, track_h) * view.pixel_h / total,
- ))),
- );
- const offset: u64 = @intFromFloat(@floor(state.document_scroll_y));
- const pos: usize = @intCast(@min(
- @as(u64, track_h -| 1),
- @as(u64, track_h) * offset / total,
- ));
- var y = pos;
- while (y < track_h and y < pos + len) : (y += 1)
- core.surface.fill(
- rect.x,
- body_y + @as(u16, @intCast(y)),
- 1,
- 1,
- .{ .bg = .{ .rgb = chrome.scroll_thumb } },
- );
- return true;
- }
-
- pub const SectionRows = if (enabled) struct {
- pub fn usableDestination(destination: pdf.OutlineDestination) ?pdf.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;
- }
-
- pub fn resolve(entries: []const pdf.OutlineEntry, ordinal: usize) ?pdf.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;
- }
-
- pub fn resolveOrdinals(
- gpa: std.mem.Allocator,
- entries: []const pdf.OutlineEntry,
- ) ![]usize {
- const unresolved = std.math.maxInt(usize);
- const ordinals = try gpa.alloc(usize, entries.len);
- @memset(ordinals, unresolved);
-
- const Pending = struct { depth: u8, ordinal: usize };
- var pending: [256]Pending = undefined;
- var pending_len: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth)
- pending_len -= 1;
-
- if (usableDestination(entry.destination) != null) {
- ordinals[ordinal] = ordinal;
- for (pending[0..pending_len]) |ancestor|
- ordinals[ancestor.ordinal] = ordinal;
- pending_len = 0;
- } else if (entry.destination == .none) {
- pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal };
- pending_len += 1;
- }
- }
- return ordinals;
- }
-
- fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize {
- const raw = title orelse {
- if (out) |buf| @memcpy(buf[0..10], "[untitled]");
- return 10;
- };
- var at: usize = 0;
- 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) {
- if (out) |buf| buf[at] = ' ';
- at += 1;
- }
- if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]);
- at += n;
- wrote = true;
- pending_space = false;
- }
- i += n;
- }
- if (!wrote) {
- if (out) |buf| @memcpy(buf[0..13], "[empty title]");
- return 13;
- }
- return at;
- }
-
- pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf.OutlineEntry) ![]u8 {
- const target = std.fs.path.basename(path);
- const ordinals = try resolveOrdinals(gpa, entries);
- defer gpa.free(ordinals);
- var total: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- const resolved = ordinals[ordinal];
- if (resolved == std.math.maxInt(usize)) continue;
- const destination = usableDestination(entries[resolved].destination) orelse unreachable;
- total += switch (destination) {
- .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
- .external => |uri| std.fmt.count("{s} ", .{uri}),
- .none => unreachable,
- };
- total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1;
- }
- const out = try gpa.alloc(u8, total);
- errdefer gpa.free(out);
- var at: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- const resolved = ordinals[ordinal];
- if (resolved == std.math.maxInt(usize)) continue;
- const destination = usableDestination(entries[resolved].destination) orelse unreachable;
- const prefix = switch (destination) {
- .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
- .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}),
- .none => unreachable,
- };
- at += prefix.len;
- const indent = @as(usize, entry.depth) * 2;
- @memset(out[at..][0..indent], ' ');
- at += indent;
- at += cleanTitle(out[at..], entry.title);
- out[at] = '\n';
- at += 1;
- }
- return out;
- }
- } else struct {};
-};
+pub const Pdf = @import("pdf_view.zig");
pub const Terminal = @import("Terminal.zig");
diff --git a/src/pdf_view.zig b/src/pdf_view.zig
new file mode 100644
index 00000000..106252a0
--- /dev/null
+++ b/src/pdf_view.zig
@@ -0,0 +1,2802 @@
+//! A PDF pane: the MuPDF document it views, page rasters and their placement,
+//! scrolling, search, pointer selection, and the sections outline.
+const panes = @import("panes.zig");
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const layout = @import("layout.zig");
+const config = @import("config.zig");
+const modal = @import("modal.zig");
+const filesystem = @import("fs.zig");
+const tracy = @import("tracy.zig");
+const image = @import("image.zig");
+const look = @import("look.zig");
+const File = panes.File;
+const Output = panes.Output;
+
+pub const enabled = @import("pardes_config").mupdf;
+pub const pdf = if (enabled) @import("mupdf") else struct {
+ pub const PageSize = struct { width: f32, height: f32 };
+ pub const Raster = struct {
+ width: usize = 0,
+ height: usize = 0,
+ stride: usize = 0,
+ len: usize = 0,
+ pub const Band = struct { y: usize = 0, height: usize = 0, len: usize = 0 };
+ pub fn wholePage(_: @This()) Band {
+ return .{};
+ }
+ pub fn band(_: @This(), _: usize, _: usize) Band {
+ return .{};
+ }
+ };
+};
+pub const Point = if (enabled) pdf.Point else void;
+pub const Quad = if (enabled) pdf.Quad else void;
+pub const Document = if (enabled) pdf.Document else opaque {};
+pub const OutlineInternalDestination = if (enabled) pdf.OutlineInternalDestination else void;
+const raster_max = 256;
+const raster_spare = 4;
+pub const page_gap_px: u32 = 8;
+const band_grain: usize = 64;
+
+pub const FitMode = if (enabled) enum { width, height } else void;
+pub const TintMode = if (enabled) pdf.TintMode else void;
+pub const TintColors = if (enabled) pdf.TintColors else void;
+pub const RenderRequest = if (enabled) pdf.RenderRequest else void;
+
+/// Dump records must remain recognizable as PDFs even in a build without
+/// MuPDF, where Look deliberately treats them as ordinary files.
+pub fn isPath(path: []const u8) bool {
+ return std.ascii.endsWithIgnoreCase(path, ".pdf");
+}
+
+pub const RasterPolicy = struct {
+ dpi: u16,
+ max_dimension: u16,
+ match_viewport: bool,
+};
+
+pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 {
+ if (std.mem.startsWith(u8, saved_tag, path) and std.mem.startsWith(u8, saved_tag[path.len..], " [")) {
+ const start = path.len + 2;
+ const end = std.mem.indexOfScalarPos(u8, saved_tag, start, ']') orelse return null;
+ var parts = std.mem.splitScalar(u8, saved_tag[start..end], '/');
+ _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null;
+ _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null;
+ if (parts.next() != null) return null;
+ return saved_tag[0 .. end + 1];
+ }
+ if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null;
+ const marker = " PdfSections ";
+ const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null;
+ const path_at = marker_at + marker.len;
+ if (!std.mem.startsWith(u8, saved_tag[path_at..], path)) return null;
+ return saved_tag[0 .. path_at + path.len];
+}
+
+pub const TintKey = if (enabled) struct {
+ mode: TintMode,
+ colors: pdf.TintColors,
+
+ pub fn eql(a: @This(), b: @This()) bool {
+ return a.mode == b.mode and
+ (a.mode == .disabled or std.meta.eql(a.colors, b.colors));
+ }
+} else void;
+
+pub const Highlight = if (enabled) pdf.Highlight else void;
+pub const Highlights = if (enabled) struct {
+ const Page = struct { page: usize, items: []const Highlight };
+ pages: [3]Page = undefined,
+ len: usize = 0,
+ /// Repaint whenever the set changes, not only when a page's raster was
+ /// invalidated (`HighlightInput.live`).
+ live: bool = false,
+
+ pub fn forPage(highlights: @This(), page: usize) []const Highlight {
+ for (highlights.pages[0..highlights.len]) |group|
+ if (group.page == page) return group.items;
+ return &.{};
+ }
+} else void;
+
+pub const HighlightInput = if (enabled) struct {
+ hover_quads: []const Quad = &.{},
+ hover_page: ?usize = null,
+ hover_color: [3]u8,
+ selection_color: [3]u8,
+ live: bool = false,
+} else void;
+
+pub fn buildHighlights(
+ state: *const State,
+ arena: std.mem.Allocator,
+ input: HighlightInput,
+) !Highlights {
+ if (comptime !enabled) return;
+ const search_len = if (state.search_results) |results| results.quads.len else 0;
+ const selection_len = if (state.selection) |selection| selection.quads.len else 0;
+ const items = try arena.alloc(Highlight, input.hover_quads.len + search_len + selection_len);
+ var result: Highlights = .{ .live = input.live };
+ var n: usize = 0;
+ // Hover, search and selection can belong to three different pages.
+ // Group them once so raster rendering can borrow a contiguous slice.
+ for ([_]?usize{ input.hover_page, state.page, state.selection_page }) |candidate| {
+ const page = candidate orelse continue;
+ var seen = false;
+ for (result.pages[0..result.len]) |group| {
+ if (group.page == page) seen = true;
+ }
+ if (seen) continue;
+ const start = n;
+ if (input.hover_page == page) for (input.hover_quads) |quad| {
+ items[n] = pdf.Highlight.init(quad, .{
+ input.hover_color[0], input.hover_color[1], input.hover_color[2], 0x2c,
+ }, .custom);
+ n += 1;
+ };
+ if (state.page == page) if (state.search_results) |results| {
+ for (results.quads) |item| {
+ items[n] = pdf.Highlight.init(item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search);
+ n += 1;
+ }
+ };
+ if (state.selection_page == page) if (state.selection) |selection| {
+ for (selection.quads) |quad| {
+ items[n] = pdf.Highlight.init(quad, .{
+ input.selection_color[0], input.selection_color[1], input.selection_color[2], 0x78,
+ }, .selection);
+ n += 1;
+ }
+ };
+ result.pages[result.len] = .{ .page = page, .items = items[start..n] };
+ result.len += 1;
+ }
+ return result;
+}
+
+test "PDF selections retain their owning page across page focus" {
+ if (comptime !enabled) return;
+ const gpa = std.testing.allocator;
+ var state = try State.open(gpa, "docs/design.pdf", 1);
+ defer state.deinit(gpa);
+ try std.testing.expect(state.page_count > 1);
+ var found = try state.document.search(gpa, 0, "Pardes");
+ defer found.deinit(gpa);
+ const quad = found.quads[0].quad;
+ const point: Point = .{
+ .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4,
+ .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4,
+ };
+ try std.testing.expectEqual(.changed, state.setSelection(gpa, point, point, true));
+ const selected = try gpa.dupe(u8, state.selection_text);
+ defer gpa.free(selected);
+ try std.testing.expect(selected.len > 0);
+ try std.testing.expect(state.focusLocation(gpa, 2, 0));
+ try std.testing.expectEqual(@as(?usize, 0), state.selection_page);
+ try std.testing.expectEqualStrings(selected, state.selection_text);
+ var arena = std.heap.ArenaAllocator.init(gpa);
+ defer arena.deinit();
+ const highlights = try buildHighlights(&state, arena.allocator(), .{
+ .hover_quads = &.{quad},
+ .hover_page = 1,
+ .hover_color = .{ 1, 2, 3 },
+ .selection_color = .{ 4, 5, 6 },
+ });
+ try std.testing.expectEqual(state.selection.?.quads.len, highlights.forPage(0).len);
+ try std.testing.expectEqual(@as(usize, 1), highlights.forPage(1).len);
+ try std.testing.expectEqual(@as(usize, 0), highlights.forPage(2).len);
+ try std.testing.expect(state.focusLocation(gpa, 1, 0));
+ try std.testing.expectEqualStrings(selected, state.selection_text);
+ try std.testing.expectEqual(.unchanged, state.setSelection(gpa, point, point, true));
+ try std.testing.expect(state.focusLocation(gpa, 2, 0));
+ state.rasters[0] = .{ .valid = true, .page = 0, .tried = true, .decorated = true };
+ state.rasters_len = 1;
+ try std.testing.expectEqual(.changed, state.setSelection(gpa, point, point, false));
+ try std.testing.expectEqual(@as(?usize, 1), state.selection_page);
+ try std.testing.expect(!state.rasters[0].tried);
+ state.clearSelection(gpa);
+ try std.testing.expect(state.selection_page == null);
+ try std.testing.expectEqual(@as(usize, 0), state.selection_text.len);
+}
+
+pub const Raster = if (enabled) struct {
+ valid: bool = false,
+ page: usize = 0,
+ rgba: []u8 = &.{},
+ /// Full page shape; rgba contains only the band below.
+ iw: usize = 0,
+ ih: usize = 0,
+ band_y: usize = 0,
+ band_h: usize = 0,
+ request: pdf.RenderRequest = .{},
+ request_valid: bool = false,
+ tried: bool = false,
+ decorated: bool = false,
+ tint_key: ?TintKey = null,
+ revision: u32 = 0,
+ /// `rgba` before its highlights and tint, kept once the page has been
+ /// highlighted: a highlight change repaints rows from it instead of
+ /// rendering the page again.
+ clean: []u8 = &.{},
+ /// The highlights baked into `rgba` (gpa-owned), so a change repaints
+ /// only the rows of the quads that differ.
+ baked: []Highlight = &.{},
+ /// `baked` is a true record (false after a render could not copy it).
+ baked_valid: bool = false,
+ /// Page rows changed since revision `patch_from`, the one last handed
+ /// to a shell; 0 when the pixels were replaced wholesale since.
+ patch_from: u32 = 0,
+ patch_rows: pdf.Document.Rows = .{ .start = 0, .end = 0 },
+} else void;
+
+pub const SectionsOutput = if (enabled) struct {
+ pane: usize,
+ serial: u32,
+ revision: u32,
+} else void;
+
+pub const SelectionUpdate = enum { failed, stationary, unchanged, changed };
+
+pub const State = if (enabled) struct {
+ path: []u8,
+ document: Document,
+ page: usize = 0,
+ page_count: usize,
+ page_sizes: []pdf.PageSize,
+ page_starts: []u64,
+ page_heights: []u32,
+ document_height: u64 = 0,
+ layout_viewport_w: u32 = 0,
+ layout_viewport_h: u32 = 0,
+ layout_fit: FitMode = .width,
+ layout_valid: bool = false,
+ document_scroll_y: f64 = 0,
+ scroll_to_page_pending: bool = true,
+ rasters: [raster_max]Raster = undefined,
+ rasters_len: usize = 0,
+ spare: [raster_spare][]u8 = @splat(&.{}),
+ spare_len: usize = 0,
+ layout_anchor_pending: bool = false,
+ layout_anchor_page: usize = 0,
+ layout_anchor_fraction: f64 = 0,
+ next_raster_revision: u32 = 0,
+ scroll_travel: f64 = 0,
+ fit: FitMode = .width,
+ tint: TintMode = .filtered,
+ pan_x: u16 = 0,
+ pan_y: u16 = 0,
+ highlights_dirty: bool = false,
+ search_reveal_pending: bool = false,
+ search_results: ?pdf.SearchResults = null,
+ selection: ?pdf.Selection = null,
+ selection_page: ?usize = null,
+ selection_text: []u8 = &.{},
+ selection_anchor: ?Point = null,
+ selection_head: ?Point = null,
+ drag_anchor: ?Point = null,
+ drag_head: ?Point = null,
+ text: []u8 = &.{},
+ text_tried: bool = false,
+ text_scroll: usize = 0,
+ text_scroll_clamp_pending: bool = false,
+ search_query: []u8 = &.{},
+ search_hit: usize = 0,
+ reveal_viewport_w: u32 = 0,
+ reveal_viewport_h: u32 = 0,
+ reveal_fit: FitMode = .width,
+ reveal_viewport_valid: bool = false,
+ outline: ?pdf.Outline = null,
+ outline_tried: bool = false,
+ sections_output: ?SectionsOutput = null,
+ outline_reveal_pending: ?pdf.OutlineInternalDestination = null,
+
+ /// Read whole and opened from memory (the bridge copies), never from
+ /// the file itself: MuPDF reads a file lazily at every page, and the
+ /// path may be a mount this editor serves, which only a read that
+ /// gives the turn up can come back from (`filesystem.readFile`).
+ pub fn open(gpa: std.mem.Allocator, path: []const u8, page_one_based: usize) !@This() {
+ const local = filesystem.localPath(path) orelse return error.NonLocalPath;
+ const bytes = try filesystem.readFile(gpa, local);
+ defer gpa.free(bytes);
+ return initDocument(gpa, path, try Document.openBytes(bytes), page_one_based);
+ }
+
+ pub fn openBytes(gpa: std.mem.Allocator, path: []const u8, bytes: []const u8, page_one_based: usize) !@This() {
+ return initDocument(gpa, path, try Document.openBytes(bytes), page_one_based);
+ }
+
+ fn initDocument(gpa: std.mem.Allocator, path: []const u8, opened: Document, page_one_based: usize) !@This() {
+ var document = opened;
+ errdefer document.deinit();
+ const page_sizes = try gpa.alloc(pdf.PageSize, document.pages);
+ errdefer gpa.free(page_sizes);
+ for (page_sizes, 0..) |*size, page| size.* = try document.pageSize(page);
+ const page_starts = try gpa.alloc(u64, document.pages);
+ errdefer gpa.free(page_starts);
+ const page_heights = try gpa.alloc(u32, document.pages);
+ errdefer gpa.free(page_heights);
+ const owned_path = try gpa.dupe(u8, path);
+ errdefer gpa.free(owned_path);
+ return .{
+ .path = owned_path,
+ .document = document,
+ .page = if (page_one_based > 0)
+ @min(page_one_based - 1, document.pages - 1)
+ else
+ 0,
+ .page_count = document.pages,
+ .page_sizes = page_sizes,
+ .page_starts = page_starts,
+ .page_heights = page_heights,
+ };
+ }
+
+ pub fn reload(state: *@This(), gpa: std.mem.Allocator) !void {
+ const preserve_anchor = !state.scroll_to_page_pending and
+ (state.layout_anchor_pending or
+ (state.layout_valid and state.page_count > 0 and state.document_height > 0));
+ var anchor_page: usize = state.layout_anchor_page;
+ var anchor_fraction: f64 = state.layout_anchor_fraction;
+ if (preserve_anchor and !state.layout_anchor_pending) {
+ anchor_page = pageAtOffset(state, state.document_scroll_y);
+ const start: f64 = @floatFromInt(state.page_starts[anchor_page]);
+ const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[anchor_page]));
+ anchor_fraction = std.math.clamp(
+ (state.document_scroll_y - start) / height,
+ 0,
+ 1,
+ );
+ }
+
+ var fresh = try @This().open(gpa, state.path, state.page + 1);
+ errdefer fresh.deinit(gpa);
+ if (state.search_query.len > 0)
+ fresh.search_query = try gpa.dupe(u8, state.search_query);
+
+ fresh.fit = state.fit;
+ fresh.tint = state.tint;
+ fresh.pan_x = state.pan_x;
+ fresh.pan_y = state.pan_y;
+ fresh.text_scroll = state.text_scroll;
+ fresh.text_scroll_clamp_pending = true;
+ fresh.search_hit = state.search_hit;
+ fresh.highlights_dirty = fresh.search_query.len > 0;
+ fresh.search_reveal_pending = state.search_reveal_pending;
+ fresh.reveal_viewport_w = state.reveal_viewport_w;
+ fresh.reveal_viewport_h = state.reveal_viewport_h;
+ fresh.reveal_fit = state.reveal_fit;
+ fresh.reveal_viewport_valid = state.reveal_viewport_valid;
+ // Revisions are part of the backend texture key. Resetting this counter
+ // while the pane serial stays live can alias a cached pre-reload page.
+ fresh.next_raster_revision = state.next_raster_revision;
+ fresh.sections_output = state.sections_output;
+ if (preserve_anchor) {
+ fresh.scroll_to_page_pending = false;
+ fresh.layout_anchor_pending = true;
+ fresh.layout_anchor_page = anchor_page;
+ fresh.layout_anchor_fraction = anchor_fraction;
+ }
+
+ var old = state.*;
+ state.* = fresh;
+ old.deinit(gpa);
+ }
+
+ pub fn invalidateRaster(state: *@This(), page: usize) void {
+ if (rasterForPage(state, page)) |raster| raster.tried = false;
+ }
+
+ pub fn invalidateAllRasters(state: *@This()) void {
+ for (state.rasters[0..state.rasters_len]) |*raster| {
+ if (raster.valid) raster.tried = false;
+ }
+ }
+
+ fn retireRgba(state: *@This(), gpa: std.mem.Allocator, rgba: []u8) void {
+ if (rgba.len == 0) return;
+ if (state.spare_len == state.spare.len) return gpa.free(rgba);
+ state.spare[state.spare_len] = rgba;
+ state.spare_len += 1;
+ }
+
+ fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void {
+ if (raster.clean.len > 0) gpa.free(raster.clean);
+ gpa.free(raster.baked);
+ state.retireRgba(gpa, raster.rgba);
+ raster.* = .{};
+ }
+
+ fn claimRgba(state: *@This(), gpa: std.mem.Allocator, bytes: usize) ?[]u8 {
+ for (state.spare[0..state.spare_len], 0..) |candidate, index| {
+ if (candidate.len != bytes) continue;
+ state.spare_len -= 1;
+ state.spare[index] = state.spare[state.spare_len];
+ return candidate;
+ }
+ return gpa.alloc(u8, bytes) catch null;
+ }
+
+ fn trimSpares(state: *@This(), gpa: std.mem.Allocator) void {
+ while (state.spare_len > 1) {
+ state.spare_len -= 1;
+ gpa.free(state.spare[state.spare_len]);
+ }
+ }
+
+ fn dropSearchResults(state: *@This(), gpa: std.mem.Allocator) void {
+ if (state.search_results) |*results| results.deinit(gpa);
+ state.search_results = null;
+ }
+
+ fn dropSelection(state: *@This(), gpa: std.mem.Allocator) void {
+ if (state.selection) |*selection| selection.deinit(gpa);
+ state.selection = null;
+ state.selection_page = null;
+ if (state.selection_text.len > 0) gpa.free(state.selection_text);
+ state.selection_text = &.{};
+ state.selection_anchor = null;
+ state.selection_head = null;
+ }
+
+ pub fn setSelection(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ start: Point,
+ end: Point,
+ invalidate_raster: bool,
+ ) SelectionUpdate {
+ if (state.selection != null and state.selection_page == state.page and
+ state.selection_anchor != null and state.selection_head != null and
+ state.selection_anchor.?.x == start.x and state.selection_anchor.?.y == start.y and
+ state.selection_head.?.x == end.x and state.selection_head.?.y == end.y) return .unchanged;
+ var selection = state.document.select(gpa, state.page, start, end) catch return .failed;
+ const text = state.document.copySelection(
+ gpa,
+ state.page,
+ selection.start,
+ selection.end,
+ ) catch {
+ selection.deinit(gpa);
+ return .failed;
+ };
+
+ const old_page = state.selection_page;
+ state.dropSelection(gpa);
+ state.selection = selection;
+ state.selection_page = state.page;
+ state.selection_text = text;
+ state.selection_anchor = start;
+ state.selection_head = end;
+ if (old_page) |page| if (page != state.page) state.invalidateRaster(page);
+ if (invalidate_raster) state.invalidateRaster(state.page);
+ return .changed;
+ }
+
+ pub fn clearDrag(state: *@This()) void {
+ state.drag_anchor = null;
+ state.drag_head = null;
+ }
+
+ pub fn clearSelection(state: *@This(), gpa: std.mem.Allocator) void {
+ const page = state.selection_page;
+ state.dropSelection(gpa);
+ if (page) |selected_page| state.invalidateRaster(selected_page);
+ }
+
+ pub fn cancelChrome(state: *@This(), gpa: std.mem.Allocator) void {
+ state.clearDrag();
+ state.clearSelection(gpa);
+ if (state.search_query.len == 0) return;
+ state.dropSearchQuery(gpa);
+ state.invalidateRaster(state.page);
+ }
+
+ fn invalidatePage(state: *@This(), gpa: std.mem.Allocator) void {
+ state.dropSearchResults(gpa);
+ state.clearDrag();
+ if (state.text.len > 0) gpa.free(state.text);
+ state.text = &.{};
+ state.text_tried = false;
+ state.text_scroll = 0;
+ state.text_scroll_clamp_pending = false;
+ state.highlights_dirty = state.search_query.len > 0;
+ state.search_reveal_pending = state.search_query.len > 0;
+ state.search_hit = 0;
+ }
+
+ fn dropSearchQuery(state: *@This(), gpa: std.mem.Allocator) void {
+ if (state.search_query.len > 0) gpa.free(state.search_query);
+ state.search_query = &.{};
+ state.search_hit = 0;
+ state.dropSearchResults(gpa);
+ state.highlights_dirty = false;
+ state.search_reveal_pending = false;
+ }
+
+ pub fn setSearchQuery(state: *@This(), gpa: std.mem.Allocator, query: []const u8) !void {
+ if (std.mem.eql(u8, state.search_query, query)) return;
+ const owned = try gpa.dupe(u8, query);
+ state.dropSearchQuery(gpa);
+ state.search_query = owned;
+ state.highlights_dirty = query.len > 0;
+ state.search_reveal_pending = query.len > 0;
+ state.invalidateRaster(state.page);
+ }
+
+ pub fn ensureText(state: *@This(), gpa: std.mem.Allocator) []const u8 {
+ if (!state.text_tried) {
+ state.text_tried = true;
+ state.text = state.document.pageText(gpa, state.page) catch &.{};
+ }
+ if (state.text_scroll_clamp_pending) {
+ const lines = std.mem.count(u8, state.text, "\n") + 1;
+ state.text_scroll = @min(state.text_scroll, lines - 1);
+ state.text_scroll_clamp_pending = false;
+ }
+ return state.text;
+ }
+
+ pub fn resolveSearch(state: *@This(), gpa: std.mem.Allocator) void {
+ if (!state.highlights_dirty) return;
+ state.highlights_dirty = false;
+ state.dropSearchResults(gpa);
+ if (state.search_query.len == 0) return;
+ const results = state.document.search(gpa, state.page, state.search_query) catch return;
+ state.search_hit = if (results.hit_count == 0)
+ 0
+ else
+ @min(state.search_hit, results.hit_count - 1);
+ state.search_results = results;
+ }
+
+ pub fn ensureOutline(state: *@This(), gpa: std.mem.Allocator) ?*const pdf.Outline {
+ if (!state.outline_tried) {
+ state.outline_tried = true;
+ state.outline = state.document.outline(gpa) catch null;
+ }
+ return if (state.outline) |*outline| outline else null;
+ }
+
+ pub fn renderSections(
+ state: *@This(),
+ pdf_gpa: std.mem.Allocator,
+ output_gpa: std.mem.Allocator,
+ ) ![]u8 {
+ const entries: []const pdf.OutlineEntry = if (state.ensureOutline(pdf_gpa)) |outline|
+ outline.entries
+ else
+ &.{};
+ return SectionRows.render(output_gpa, state.path, entries);
+ }
+
+ pub fn sectionDestination(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ ordinal: usize,
+ ) ?pdf.OutlineDestination {
+ const outline = state.ensureOutline(gpa) orelse return null;
+ return SectionRows.resolve(outline.entries, ordinal);
+ }
+
+ /// Apply the one-based page/hit location encoded in a PDF search row.
+ /// Returns whether host pane cursor chrome must be reset.
+ pub fn focusLocation(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ line: usize,
+ column: usize,
+ ) bool {
+ const changed = line > 0 and state.activatePage(gpa, line - 1, true);
+ if (column > 0 and state.search_query.len > 0) {
+ state.search_hit = column - 1;
+ state.search_reveal_pending = true;
+ }
+ return changed;
+ }
+
+ /// Change the document page while leaving pane cursor/selection chrome to
+ /// the UI adapter. Returns whether that pane-local chrome must be reset.
+ pub fn activatePage(
+ state: *@This(),
+ gpa: std.mem.Allocator,
+ page: usize,
+ reveal: bool,
+ ) bool {
+ state.outline_reveal_pending = null;
+ const next = @min(page, state.page_count -| 1);
+ const changed = next != state.page;
+ if (changed) {
+ state.invalidatePage(gpa);
+ state.page = next;
+ }
+ if (reveal) {
+ state.scroll_to_page_pending = true;
+ if (state.layout_valid) {
+ state.document_scroll_y = @floatFromInt(state.page_starts[next]);
+ state.scroll_to_page_pending = false;
+ }
+ } else {
+ state.search_reveal_pending = false;
+ }
+ return changed;
+ }
+
+ pub fn toggleFit(state: *@This()) void {
+ state.fit = if (state.fit == .width) .height else .width;
+ state.pan_x = 0;
+ state.pan_y = 0;
+ state.layout_valid = false;
+ state.scroll_to_page_pending = true;
+ state.search_reveal_pending = state.search_query.len > 0;
+ }
+
+ pub fn toggleTint(state: *@This()) void {
+ state.tint = state.tint.next();
+ state.invalidateAllRasters();
+ }
+
+ pub fn queueOutlineReveal(
+ state: *@This(),
+ destination: pdf.OutlineInternalDestination,
+ ) void {
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ state.outline_reveal_pending = destination;
+ }
+
+ pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void {
+ gpa.free(state.path);
+ for (state.rasters[0..state.rasters_len]) |raster| {
+ if (raster.rgba.len > 0) gpa.free(raster.rgba);
+ if (raster.clean.len > 0) gpa.free(raster.clean);
+ gpa.free(raster.baked);
+ }
+ for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba);
+ gpa.free(state.page_sizes);
+ gpa.free(state.page_starts);
+ gpa.free(state.page_heights);
+ if (state.text.len > 0) gpa.free(state.text);
+ if (state.search_query.len > 0) gpa.free(state.search_query);
+ if (state.search_results) |*results| results.deinit(gpa);
+ if (state.selection) |*selection| selection.deinit(gpa);
+ if (state.selection_text.len > 0) gpa.free(state.selection_text);
+ if (state.outline) |*outline| outline.deinit(gpa);
+ state.document.deinit();
+ state.* = undefined;
+ }
+} else void;
+
+pub const SearchOutput = struct {
+ bytes: usize = 0,
+ rows: usize = 0,
+ anchor: ?usize = null,
+};
+
+pub fn textLines(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+) ![]const []const u8 {
+ if (comptime !enabled) return &.{};
+ const page_text = state.ensureText(gpa);
+ const lines = try arena.alloc([]const u8, std.mem.count(u8, page_text, "\n") + 1);
+ var it = std.mem.splitScalar(u8, page_text, '\n');
+ var n: usize = 0;
+ while (it.next()) |line| : (n += 1) lines[n] = line;
+ return lines;
+}
+
+pub fn visibleText(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ max_rows: usize,
+) ![]const u8 {
+ if (comptime !enabled) return "";
+ const page_text = state.ensureText(gpa);
+ var start: usize = 0;
+ for (0..state.text_scroll) |_| {
+ const newline = std.mem.indexOfScalarPos(u8, page_text, start, '\n') orelse
+ return arena.dupe(u8, "");
+ start = newline + 1;
+ }
+ if (max_rows == 0) return arena.dupe(u8, "");
+
+ var end = start;
+ var row: usize = 0;
+ while (row < max_rows) : (row += 1) {
+ const newline = std.mem.indexOfScalarPos(u8, page_text, end, '\n') orelse {
+ end = page_text.len;
+ break;
+ };
+ if (row + 1 == max_rows) {
+ end = newline;
+ break;
+ }
+ end = newline + 1;
+ }
+ return arena.dupe(u8, page_text[start..end]);
+}
+
+/// Materialize exact MuPDF logical hits as `path:PAGE:HIT query` rows. The
+/// same hit numbering drives persistent highlights and later reveal actions.
+pub fn searchRows(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ pattern: []const u8,
+ from_cursor: bool,
+ out: []u8,
+) !SearchOutput {
+ if (comptime !enabled) return .{};
+ try state.setSearchQuery(gpa, pattern);
+ const shown = std.fs.path.basename(state.path);
+ var result: SearchOutput = .{};
+ const max_hits = 512;
+ var snippet_len = @min(pattern.len, 200);
+ while (snippet_len > 0 and snippet_len < pattern.len and pattern[snippet_len] & 0xc0 == 0x80)
+ snippet_len -= 1;
+ const snippet = pattern[0..snippet_len];
+ for (0..state.page_count) |page| {
+ if (result.rows >= max_hits) break;
+ var found = try state.document.search(gpa, page, pattern);
+ defer found.deinit(gpa);
+
+ const cursor_hit: ?usize = if (from_cursor and page == state.page and state.selection_page == page) cursor: {
+ const selection = state.selection orelse break :cursor null;
+ for (found.quads) |item| {
+ const q = item.quad;
+ const center: Point = .{
+ .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;
+ const line = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
+ shown, page + 1, hit_index + 1, snippet,
+ });
+ if (line.len > out.len - result.bytes) return result;
+ if (from_cursor and
+ (page < state.page or
+ (page == state.page and cursor_hit != null and hit_index <= cursor_hit.?)))
+ result.anchor = result.rows;
+ @memcpy(out[result.bytes..][0..line.len], line);
+ result.bytes += line.len;
+ result.rows += 1;
+ }
+ }
+ return result;
+}
+
+pub const Viewport = struct { pixel_w: u32, pixel_h: u32 };
+pub const VisiblePages = struct { first: usize = 0, len: usize = 0 };
+pub const PanAxis = enum { horizontal, vertical };
+pub const PanResult = enum { moved, edge, unavailable };
+pub const ScrollResult = struct { active_page: usize };
+pub const CellPixels = struct { w: u16, h: u16 };
+pub const NormalHost = enum {
+ none,
+ leader,
+ command_line,
+ search,
+ search_forward,
+ search_backward,
+};
+pub const NormalResult = struct {
+ host: NormalHost = .none,
+ page_changed: bool = false,
+};
+
+const PlacedGeometry = struct {
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+};
+
+pub const PlacedRaster = if (enabled) struct {
+ page: usize,
+ revision: u32,
+ fit: FitMode,
+ pan_x: u16,
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ rgba: []const u8,
+ width: usize,
+ band_height: usize,
+} else void;
+
+const VisibleRows = struct {
+ base: image.NativeGeometry,
+ y0: u32,
+ y1: u32,
+ dst_y: u32,
+ dst_h: u32,
+ pixel_offset_y: f32,
+};
+
+pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest {
+ if (comptime !enabled) return;
+ return .{
+ .dpi = policy.dpi,
+ .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0,
+ .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0,
+ .max_dimension = policy.max_dimension,
+ };
+}
+
+fn pageHeight(size: pdf.PageSize, viewport: Viewport, fit: FitMode) u32 {
+ if (comptime !enabled) return 0;
+ if (fit == .height) return viewport.pixel_h;
+ const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) *
+ @as(f64, size.height) / @as(f64, size.width);
+ return @max(1, @as(u32, @intFromFloat(@min(
+ @as(f64, @floatFromInt(std.math.maxInt(u32))),
+ @round(scaled),
+ ))));
+}
+
+fn pageAtOffset(state: *const State, offset: f64) usize {
+ if (comptime !enabled) return 0;
+ const y: u64 = @intFromFloat(std.math.clamp(
+ @floor(offset),
+ 0,
+ @as(f64, @floatFromInt(state.document_height -| 1)),
+ ));
+ var lo: usize = 0;
+ var hi: usize = state.page_count;
+ while (lo + 1 < hi) {
+ const mid = lo + (hi - lo) / 2;
+ if (state.page_starts[mid] <= y) lo = mid else hi = mid;
+ }
+ const end = state.page_starts[lo] + state.page_heights[lo];
+ return if (y >= end and lo + 1 < state.page_count) lo + 1 else lo;
+}
+
+fn pageVisible(state: *const State, page: usize, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y;
+ const bottom = top + @as(f64, @floatFromInt(state.page_heights[page]));
+ return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h));
+}
+
+pub fn visiblePages(state: *const State, viewport: Viewport) VisiblePages {
+ if (comptime !enabled) return .{};
+ var out: VisiblePages = .{};
+ var page = pageAtOffset(state, state.document_scroll_y);
+ if (page > 0 and pageVisible(state, page - 1, viewport)) page -= 1;
+ out.first = page;
+ while (page < state.page_count) : (page += 1) {
+ const top = @as(f64, @floatFromInt(state.page_starts[page])) - state.document_scroll_y;
+ if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break;
+ if (pageVisible(state, page, viewport)) out.len += 1;
+ }
+ return out;
+}
+
+fn visibleContains(visible: VisiblePages, page: usize) bool {
+ return page >= visible.first and page - visible.first < visible.len;
+}
+
+pub fn rasterForPage(state: *State, page: usize) ?*Raster {
+ if (comptime !enabled) return null;
+ for (state.rasters[0..state.rasters_len]) |*raster|
+ if (raster.valid and raster.page == page) return raster;
+ return null;
+}
+
+fn rasterForPageConst(state: *const State, page: usize) ?*const Raster {
+ if (comptime !enabled) return null;
+ for (state.rasters[0..state.rasters_len]) |*raster|
+ if (raster.valid and raster.page == page) return raster;
+ return null;
+}
+
+fn visibleRows(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ iw: usize,
+ ih: usize,
+) ?VisibleRows {
+ if (comptime !enabled) return null;
+ const page_h = state.page_heights[page];
+ const base = image.nativeGeometry(
+ iw,
+ ih,
+ viewport.pixel_w,
+ page_h,
+ switch (state.fit) {
+ .width => .width,
+ .height => .height,
+ },
+ state.pan_x,
+ 0,
+ ) orelse return null;
+ if (base.dst.h == 0 or base.src.h == 0) return null;
+
+ const scroll_floor = @floor(state.document_scroll_y);
+ const fractional: f32 = @floatCast(state.document_scroll_y - scroll_floor);
+ const scroll_i: i64 = @intFromFloat(@min(
+ scroll_floor,
+ @as(f64, @floatFromInt(std.math.maxInt(i64))),
+ ));
+ const start_i: i64 = @intCast(@min(
+ state.page_starts[page],
+ @as(u64, std.math.maxInt(i64)),
+ ));
+ const full_y = start_i - scroll_i + @as(i64, base.dst.y);
+ const full_bottom = full_y + @as(i64, base.dst.h);
+ const visible_y = @max(@as(i64, 0), full_y);
+ const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom);
+ if (visible_bottom <= visible_y) return null;
+
+ const rel_y0: u64 = @intCast(visible_y - full_y);
+ const rel_y1: u64 = @intCast(visible_bottom - full_y);
+ const src_y0: u32 = base.src.y + @as(u32, @intCast(
+ rel_y0 * base.src.h / base.dst.h,
+ ));
+ const src_y1: u32 = base.src.y + @as(u32, @intCast(@min(
+ @as(u64, base.src.h),
+ (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h,
+ )));
+ if (src_y1 <= src_y0) return null;
+ return .{
+ .base = base,
+ .y0 = src_y0,
+ .y1 = src_y1,
+ .dst_y = @intCast(visible_y),
+ .dst_h = @intCast(visible_bottom - visible_y),
+ .pixel_offset_y = -fractional,
+ };
+}
+
+fn placedGeometry(
+ state: *const State,
+ raster: *const Raster,
+ viewport: Viewport,
+ page: usize,
+) ?PlacedGeometry {
+ if (comptime !enabled) return null;
+ const rows = visibleRows(state, viewport, page, raster.iw, raster.ih) orelse return null;
+ const band_y: u32 = @intCast(raster.band_y);
+ const band_end: u32 = @intCast(raster.band_y + raster.band_h);
+ if (rows.y0 < band_y or rows.y1 > band_end) return null;
+ return .{
+ .geometry = .{
+ .src = .{
+ .x = rows.base.src.x,
+ .y = rows.y0 - band_y,
+ .w = rows.base.src.w,
+ .h = rows.y1 - rows.y0,
+ },
+ .dst = .{
+ .x = rows.base.dst.x,
+ .y = rows.dst_y,
+ .w = rows.base.dst.w,
+ .h = rows.dst_h,
+ },
+ },
+ .pixel_offset_y = rows.pixel_offset_y,
+ };
+}
+
+pub fn placedRaster(state: *const State, viewport: Viewport, page: usize) ?PlacedRaster {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, page) orelse return null;
+ if (raster.rgba.len == 0) return null;
+ const placed = placedGeometry(state, raster, viewport, page) orelse return null;
+ return .{
+ .page = page,
+ .revision = raster.revision,
+ .fit = state.fit,
+ .pan_x = state.pan_x,
+ .geometry = placed.geometry,
+ .pixel_offset_y = placed.pixel_offset_y,
+ .rgba = raster.rgba,
+ .width = raster.iw,
+ .band_height = raster.band_h,
+ };
+}
+
+pub fn activeGeometry(state: *const State, viewport: Viewport) ?image.NativeGeometry {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, state.page) orelse return null;
+ const placed = placedGeometry(state, raster, viewport, state.page) orelse return null;
+ return placed.geometry;
+}
+
+pub fn pageAtViewportY(state: *const State, local_y: f64) ?usize {
+ if (comptime !enabled) return null;
+ if (!state.layout_valid or !std.math.isFinite(local_y) or local_y < 0) return null;
+ const document_y = state.document_scroll_y + local_y;
+ const page = pageAtOffset(state, document_y);
+ const start: f64 = @floatFromInt(state.page_starts[page]);
+ if (document_y < start or
+ document_y >= start + @as(f64, @floatFromInt(state.page_heights[page]))) return null;
+ return page;
+}
+
+pub fn pageReady(state: *const State, viewport: Viewport, page: usize) bool {
+ if (comptime !enabled) return false;
+ return placedRaster(state, viewport, page) != null;
+}
+
+pub fn nativeReady(state: *const State, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ for (state.rasters[0..state.rasters_len]) |*raster| {
+ if (raster.valid and raster.rgba.len > 0 and
+ placedGeometry(state, raster, viewport, raster.page) != null) return true;
+ }
+ return false;
+}
+
+pub fn pointAtPage(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ px: i64,
+ py: i64,
+ clamp_to_page: bool,
+) ?Point {
+ if (comptime !enabled) return null;
+ const raster = rasterForPageConst(state, page) orelse return null;
+ if (raster.rgba.len == 0) return null;
+ const placed = placedGeometry(state, raster, viewport, page) orelse return null;
+ return pointAtGeometry(
+ placed.geometry,
+ placed.pixel_offset_y,
+ raster.iw,
+ raster.ih,
+ raster.band_y,
+ px,
+ py,
+ clamp_to_page,
+ );
+}
+
+fn slotShape(slot: *const Raster) pdf.Raster {
+ const stride = slot.iw * 4;
+ return .{ .width = slot.iw, .height = slot.ih, .stride = stride, .len = stride * slot.ih };
+}
+
+fn flinging(state: *const State, viewport: Viewport) bool {
+ if (comptime !enabled) return false;
+ return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h));
+}
+
+fn wantedBand(
+ state: *const State,
+ viewport: Viewport,
+ page: usize,
+ shape: pdf.Raster,
+ is_flinging: bool,
+) pdf.Raster.Band {
+ if (!is_flinging) return shape.wholePage();
+ const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse
+ return shape.wholePage();
+ const first = (@as(usize, rows.y0) / band_grain) * band_grain;
+ const last = std.math.divCeil(usize, @as(usize, rows.y1), band_grain) catch
+ return shape.wholePage();
+ return shape.band(first, last * band_grain - first);
+}
+
+fn reconcile(
+ state: *State,
+ gpa: std.mem.Allocator,
+ request: RenderRequest,
+ tint_key: TintKey,
+ highlights: Highlights,
+ visible: VisiblePages,
+ viewport: Viewport,
+) void {
+ if (comptime !enabled) return;
+ const tz = tracy.zone(@src(), "pdf.reconcile");
+ defer tz.end();
+
+ // Remove first so arriving pages can claim departing page buffers. Only
+ // the final visible set can be seen, so a fling skips crossed-over pages.
+ var index: usize = 0;
+ while (index < state.rasters_len) {
+ if (visibleContains(visible, state.rasters[index].page)) {
+ index += 1;
+ continue;
+ }
+ state.retireRaster(gpa, &state.rasters[index]);
+ state.rasters_len -= 1;
+ if (index != state.rasters_len)
+ state.rasters[index] = state.rasters[state.rasters_len];
+ }
+
+ const is_flinging = flinging(state, viewport);
+ var page = visible.first;
+ const end = visible.first + visible.len;
+ while (page < end) : (page += 1) {
+ var raster = rasterForPage(state, page);
+ if (raster == null) {
+ if (state.rasters_len == state.rasters.len) continue;
+ state.rasters[state.rasters_len] = .{ .valid = true, .page = page };
+ state.rasters_len += 1;
+ raster = &state.rasters[state.rasters_len - 1];
+ }
+ const slot = raster.?;
+ const page_highlights = highlights.forPage(page);
+ const decorated = page_highlights.len > 0;
+ const base_stale = !slot.request_valid or !slot.request.eql(request) or
+ slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or
+ slot.rgba.len == 0 or slot.band_h == 0;
+ const uncovered = !base_stale and uncovered: {
+ const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging);
+ break :uncovered slot.band_y > want.y or
+ slot.band_y + slot.band_h < want.y + want.height;
+ };
+ if (!base_stale and !uncovered) {
+ if (slot.tried and slot.decorated == decorated and
+ !(highlights.live and !sameHighlights(slot.baked, page_highlights))) continue;
+ // Invalidated, but it already shows exactly these highlights
+ // (a preview withdrawn that was never drawn, on every pointer
+ // move over a page): nothing to render.
+ if (slot.baked_valid and sameHighlights(slot.baked, page_highlights)) {
+ slot.tried = true;
+ continue;
+ }
+ // Only the highlights moved: repaint their rows in place.
+ if (repaintHighlights(state, gpa, slot, request, tint_key, page_highlights)) continue;
+ }
+
+ slot.tried = true;
+ slot.request = request;
+ slot.request_valid = true;
+ const shape_or_null = shape: {
+ const tz_measure = tracy.zone(@src(), "pdf.measure");
+ defer tz_measure.end();
+ break :shape state.document.measureRenderAt(page, request) catch null;
+ };
+ const shape = shape_or_null orelse continue;
+ const want = wantedBand(state, viewport, page, shape, is_flinging);
+ const fresh = state.claimRgba(gpa, want.len) orelse continue;
+ const filled = filled: {
+ {
+ const tz_render = tracy.zone(@src(), "pdf.render_into");
+ defer tz_render.end();
+ // A highlighted page keeps its clean rows for repaints.
+ const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0);
+ state.document.renderIntoAtWithPaper(
+ page,
+ request,
+ shape,
+ want,
+ if (keep_clean) &.{} else page_highlights,
+ fresh,
+ pardes.platform == .macos,
+ ) catch break :filled false;
+ if (slot.clean.len != 0 and (!keep_clean or slot.clean.len != fresh.len)) {
+ gpa.free(slot.clean);
+ slot.clean = &.{};
+ }
+ if (keep_clean) {
+ if (slot.clean.len == 0) slot.clean = gpa.alloc(u8, fresh.len) catch &.{};
+ if (slot.clean.len == fresh.len) @memcpy(slot.clean, fresh);
+ state.document.paintHighlightsAt(page, request, shape, want, page_highlights, fresh) catch
+ break :filled false;
+ }
+ }
+ const tz_tint = tracy.zone(@src(), "pdf.tint");
+ defer tz_tint.end();
+ pdf.tintRgba(fresh, tint_key.mode, tint_key.colors) catch
+ break :filled false;
+ break :filled true;
+ };
+ if (!filled) {
+ state.retireRgba(gpa, fresh);
+ // `clean` may already hold the band that failed.
+ if (slot.clean.len > 0) gpa.free(slot.clean);
+ slot.clean = &.{};
+ continue;
+ }
+
+ state.retireRgba(gpa, slot.rgba);
+ slot.rgba = fresh;
+ slot.iw = shape.width;
+ slot.ih = shape.height;
+ slot.band_y = want.y;
+ slot.band_h = want.height;
+ slot.decorated = decorated;
+ slot.tint_key = tint_key;
+ if (gpa.dupe(Highlight, page_highlights)) |copy| {
+ gpa.free(slot.baked);
+ slot.baked = copy;
+ slot.baked_valid = true;
+ } else |_| {
+ // Unrecorded highlights cannot be compared or diffed.
+ slot.baked_valid = false;
+ if (slot.clean.len > 0) gpa.free(slot.clean);
+ slot.clean = &.{};
+ }
+ slot.patch_from = 0;
+ slot.patch_rows = .{ .start = 0, .end = 0 };
+ state.next_raster_revision +%= 1;
+ if (state.next_raster_revision == 0) state.next_raster_revision = 1;
+ slot.revision = state.next_raster_revision;
+ }
+ state.trimSpares(gpa);
+}
+
+/// Repaint only the rows where the old and new highlights differ: the
+/// clean rows kept from the page's last render, MuPDF's highlight pass
+/// (the whole new set) over them, the per-pixel tint — the operations a
+/// full render performs, in its order, on fewer rows and without running
+/// the page again. False leaves the slot for the full path.
+fn repaintHighlights(
+ state: *State,
+ gpa: std.mem.Allocator,
+ slot: *Raster,
+ request: RenderRequest,
+ tint_key: TintKey,
+ page_highlights: []const Highlight,
+) bool {
+ const tz = tracy.zone(@src(), "pdf.repaint_highlights");
+ defer tz.end();
+ if (slot.clean.len != slot.rgba.len or !slot.baked_valid) return false;
+ if (sameHighlights(slot.baked, page_highlights)) {
+ slot.tried = true;
+ return true;
+ }
+ const baked = gpa.dupe(Highlight, page_highlights) catch return false;
+ const changed = changedHighlights(gpa, slot.baked, page_highlights) catch {
+ gpa.free(baked);
+ return false;
+ };
+ defer gpa.free(changed);
+ var dirty = state.document.highlightRows(slot.page, request, changed) catch {
+ gpa.free(baked);
+ return false;
+ };
+ slot.tried = true;
+ dirty.start = @max(dirty.start, slot.band_y);
+ dirty.end = @min(dirty.end, slot.band_y + slot.band_h);
+ if (dirty.start < dirty.end) {
+ const shape = slotShape(slot);
+ const band = shape.band(dirty.start, dirty.end - dirty.start);
+ const offset = (dirty.start - slot.band_y) * shape.stride;
+ const out = slot.rgba[offset..][0..band.len];
+ @memcpy(out, slot.clean[offset..][0..band.len]);
+ // On failure the rows are half-made: let the full path redo them.
+ state.document.paintHighlightsAt(slot.page, request, shape, band, page_highlights, out) catch {
+ gpa.free(baked);
+ slot.tried = false;
+ return false;
+ };
+ pdf.tintRgba(out, tint_key.mode, tint_key.colors) catch {
+ gpa.free(baked);
+ slot.tried = false;
+ return false;
+ };
+ slot.patch_rows = unionRows(slot.patch_rows, dirty);
+ state.next_raster_revision +%= 1;
+ if (state.next_raster_revision == 0) state.next_raster_revision = 1;
+ slot.revision = state.next_raster_revision;
+ }
+ gpa.free(slot.baked);
+ slot.baked = baked;
+ slot.decorated = page_highlights.len > 0;
+ return true;
+}
+
+fn sameHighlights(a: []const Highlight, b: []const Highlight) bool {
+ return std.mem.eql(u8, std.mem.sliceAsBytes(a), std.mem.sliceAsBytes(b));
+}
+
+/// The highlights in one set and not the other, as multisets (a quad
+/// painted twice blends twice). When only the order changed, blending
+/// order did, so everything counts.
+// ponytail: quadratic match; past 64k comparisons every quad counts.
+fn changedHighlights(gpa: std.mem.Allocator, old: []const Highlight, new: []const Highlight) ![]Highlight {
+ const out = try gpa.alloc(Highlight, old.len + new.len);
+ errdefer gpa.free(out);
+ var n: usize = 0;
+ if (old.len * new.len <= 1 << 16) {
+ const matched = try gpa.alloc(bool, new.len);
+ defer gpa.free(matched);
+ @memset(matched, false);
+ for (old) |h| {
+ for (new, matched) |candidate, *used| {
+ if (!used.* and std.mem.eql(u8, std.mem.asBytes(&h), std.mem.asBytes(&candidate))) {
+ used.* = true;
+ break;
+ }
+ } else {
+ out[n] = h;
+ n += 1;
+ }
+ }
+ for (new, matched) |h, used| if (!used) {
+ out[n] = h;
+ n += 1;
+ };
+ }
+ if (n == 0) {
+ @memcpy(out[0..old.len], old);
+ @memcpy(out[old.len..], new);
+ return out;
+ }
+ // Freed by length: hand back exactly what the caller will free.
+ return gpa.realloc(out, n);
+}
+
+fn unionRows(a: pdf.Document.Rows, b: pdf.Document.Rows) pdf.Document.Rows {
+ if (a.start >= a.end) return b;
+ if (b.start >= b.end) return a;
+ return .{ .start = @min(a.start, b.start), .end = @max(a.end, b.end) };
+}
+
+pub fn renderFrame(
+ state: *State,
+ gpa: std.mem.Allocator,
+ arena: std.mem.Allocator,
+ viewport: Viewport,
+ policy: RasterPolicy,
+ tint_key: TintKey,
+ highlight_input: HighlightInput,
+) VisiblePages {
+ if (comptime !enabled) return .{};
+ ensureLayout(state, viewport);
+ state.resolveSearch(gpa);
+ const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{};
+ const request = renderRequest(viewport, policy);
+ var visible = visiblePages(state, viewport);
+ reconcile(state, gpa, request, tint_key, highlights, visible, viewport);
+
+ rearmSearchReveal(state, viewport);
+ revealSearch(state, viewport, activeGeometry(state, viewport));
+ visible = visiblePages(state, viewport);
+ reconcile(state, gpa, request, tint_key, highlights, visible, viewport);
+ return visible;
+}
+
+pub fn normalizedPixel(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))));
+}
+
+pub fn scaledStep(base: u32, count: u32) u32 {
+ return @intCast(@min(
+ @as(u64, std.math.maxInt(u32)),
+ @as(u64, base) * @max(@as(u64, 1), count),
+ ));
+}
+
+pub fn scrollDocument(state: *State, viewport: Viewport, delta_pixels: f64) ?ScrollResult {
+ if (comptime !enabled) return null;
+ if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return null;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ const next = std.math.clamp(state.document_scroll_y + delta_pixels, 0, max_scroll);
+ if (next == state.document_scroll_y) return null;
+ state.scroll_travel += @abs(next - state.document_scroll_y);
+ state.document_scroll_y = next;
+ return .{ .active_page = pageAtOffset(state, next) };
+}
+
+pub fn panPixels(
+ state: *State,
+ geometry: image.NativeGeometry,
+ axis: PanAxis,
+ direction: i8,
+ display_pixels: u32,
+) PanResult {
+ if (comptime !enabled) return .unavailable;
+ const raster = rasterForPage(state, state.page) orelse return .unavailable;
+ const source_full: u32 = @intCast(switch (axis) {
+ .horizontal => raster.iw,
+ .vertical => raster.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 => &state.pan_x,
+ .vertical => &state.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 setHorizontalEdge(state: *State, geometry: image.NativeGeometry, end: bool) void {
+ if (comptime !enabled) return;
+ const raster = rasterForPage(state, state.page) orelse return;
+ if (raster.iw <= geometry.src.w) return;
+ state.pan_x = if (end) std.math.maxInt(u16) else 0;
+}
+
+fn rearmSearchReveal(state: *State, viewport: Viewport) void {
+ if (comptime !enabled) return;
+ if (state.search_query.len == 0) return;
+ if (!state.reveal_viewport_valid or
+ state.reveal_viewport_w != viewport.pixel_w or
+ state.reveal_viewport_h != viewport.pixel_h or
+ state.reveal_fit != state.fit)
+ state.search_reveal_pending = true;
+}
+
+pub fn revealSearch(
+ state: *State,
+ viewport: Viewport,
+ geometry: ?image.NativeGeometry,
+) void {
+ if (comptime !enabled) return;
+ if (!state.search_reveal_pending) return;
+ state.reveal_viewport_w = viewport.pixel_w;
+ state.reveal_viewport_h = viewport.pixel_h;
+ state.reveal_fit = state.fit;
+ state.reveal_viewport_valid = true;
+ const results = state.search_results orelse {
+ state.search_reveal_pending = false;
+ return;
+ };
+ if (results.hit_count == 0 or results.quads.len == 0) {
+ state.search_reveal_pending = false;
+ return;
+ }
+ state.search_hit = @min(state.search_hit, results.hit_count - 1);
+ const q = for (results.quads) |item| {
+ if (item.hit == state.search_hit) break item.quad;
+ } else {
+ state.search_reveal_pending = false;
+ return;
+ };
+ state.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;
+ if (state.fit == .width) {
+ ensureLayout(state, viewport);
+ const page_y = @as(f64, @floatFromInt(state.page_starts[state.page])) +
+ @as(f64, center_y) * @as(f64, @floatFromInt(state.page_heights[state.page]));
+ const wanted = page_y - @as(f64, @floatFromInt(viewport.pixel_h)) / 2;
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ state.document_scroll_y = std.math.clamp(wanted, 0, max_scroll);
+ return;
+ }
+ const placed = geometry orelse return;
+ const raster = rasterForPage(state, state.page) orelse return;
+ const full: u32 = @intCast(raster.iw);
+ const at = normalizedPixel(center_x, raster.iw);
+ if (at >= placed.src.x and at < placed.src.x + placed.src.w) return;
+ const overflow = full -| placed.src.w;
+ if (overflow == 0) return;
+ const wanted = @min(overflow, at -| placed.src.w / 2);
+ state.pan_x = @intCast(
+ (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
+ );
+}
+
+pub fn stepPage(state: *State, gpa: std.mem.Allocator, delta: i64) bool {
+ const current: i64 = @intCast(state.page);
+ const last: i64 = @intCast(state.page_count -| 1);
+ return state.activatePage(
+ gpa,
+ @intCast(std.math.clamp(current + delta, 0, last)),
+ true,
+ );
+}
+
+fn scrollNormal(
+ state: *State,
+ gpa: std.mem.Allocator,
+ viewport: ?Viewport,
+ delta_pixels: f64,
+) bool {
+ const view = viewport orelse return false;
+ ensureLayout(state, view);
+ const result = scrollDocument(state, view, delta_pixels) orelse return false;
+ return result.active_page != state.page and
+ state.activatePage(gpa, result.active_page, false);
+}
+
+fn moveRows(
+ state: *State,
+ gpa: std.mem.Allocator,
+ native_images: bool,
+ viewport: ?Viewport,
+ cell_pixels: CellPixels,
+ direction: i8,
+ count: u32,
+) bool {
+ if (!native_images) {
+ const pages: i64 = @intCast(@max(@as(u32, 1), count));
+ return stepPage(state, gpa, if (direction > 0) pages else -pages);
+ }
+ return scrollNormal(
+ state,
+ gpa,
+ viewport,
+ @as(f64, @floatFromInt(scaledStep(cell_pixels.h, count))) * direction,
+ );
+}
+
+fn movePage(
+ state: *State,
+ gpa: std.mem.Allocator,
+ native_images: bool,
+ viewport: ?Viewport,
+ cell_pixels: CellPixels,
+ direction: i8,
+ kind: modal.Normal.Page,
+ count: u32,
+) bool {
+ if (!native_images) {
+ const pages: i64 = @intCast(@max(@as(u32, 1), count));
+ return stepPage(state, gpa, if (direction > 0) pages else -pages);
+ }
+ const base: u32 = switch (kind) {
+ .half_down, .half_up => if (viewport) |view|
+ @max(@as(u32, 1), view.pixel_h / 2)
+ else
+ cell_pixels.h,
+ .down, .up => if (viewport) |view| view.pixel_h else cell_pixels.h,
+ };
+ return scrollNormal(
+ state,
+ gpa,
+ viewport,
+ @as(f64, @floatFromInt(scaledStep(base, count))) * direction,
+ );
+}
+
+pub fn applyNormal(
+ state: *State,
+ gpa: std.mem.Allocator,
+ semantic: modal.Normal.Action,
+ native_images: bool,
+ cell_pixels: CellPixels,
+ viewport: ?Viewport,
+ geometry: ?image.NativeGeometry,
+) NormalResult {
+ if (comptime !enabled) return .{};
+ var result: NormalResult = .{};
+ switch (semantic) {
+ .escape => state.cancelChrome(gpa),
+ .move => |move| switch (move.motion) {
+ .down => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ move.count,
+ ),
+ .up => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ move.count,
+ ),
+ .left => if (native_images) if (geometry) |placed| {
+ _ = panPixels(state, placed, .horizontal, -1, scaledStep(cell_pixels.w, move.count));
+ },
+ .right => if (native_images) if (geometry) |placed| {
+ _ = panPixels(state, placed, .horizontal, 1, scaledStep(cell_pixels.w, move.count));
+ },
+ else => {},
+ },
+ .goto => |go| switch (go.target) {
+ .file_start => result.page_changed = state.activatePage(
+ gpa,
+ if (go.explicit_count) go.count -| 1 else 0,
+ true,
+ ),
+ .last_line => result.page_changed = state.activatePage(gpa, state.page_count -| 1, true),
+ .line_start, .first_nonws => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, false),
+ .line_end => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, true),
+ .line_down => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ go.count,
+ ),
+ .line_up => result.page_changed = moveRows(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ go.count,
+ ),
+ else => {},
+ },
+ .line => |line| switch (line) {
+ .start, .first_nonws => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, false),
+ .end => if (native_images) if (geometry) |placed|
+ setHorizontalEdge(state, placed, true),
+ },
+ .goto_line => |go| {
+ if (go.explicit)
+ result.page_changed = state.activatePage(gpa, go.count -| 1, true);
+ },
+ .page => |page| result.page_changed = switch (page.kind) {
+ .half_down, .down => movePage(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ 1,
+ page.kind,
+ page.count,
+ ),
+ .half_up, .up => movePage(
+ state,
+ gpa,
+ native_images,
+ viewport,
+ cell_pixels,
+ -1,
+ page.kind,
+ page.count,
+ ),
+ },
+ .view => |view| result.page_changed = switch (view) {
+ .scroll_down => moveRows(state, gpa, native_images, viewport, cell_pixels, 1, 1),
+ .scroll_up => moveRows(state, gpa, native_images, viewport, cell_pixels, -1, 1),
+ else => false,
+ },
+ .leader => result.host = .leader,
+ .command_line => result.host = .command_line,
+ .search => result.host = .search,
+ .search_step => |direction| result.host = if (direction == .forward)
+ .search_forward
+ else
+ .search_backward,
+ else => {},
+ }
+ return result;
+}
+
+pub fn captureLayoutAnchor(state: *State) void {
+ if (comptime !enabled) return;
+ if (!state.layout_valid or state.page_count == 0 or state.document_height == 0) return;
+ const page = pageAtOffset(state, state.document_scroll_y);
+ const start: f64 = @floatFromInt(state.page_starts[page]);
+ const height: f64 = @floatFromInt(@max(@as(u32, 1), state.page_heights[page]));
+ state.layout_anchor_page = page;
+ state.layout_anchor_fraction = std.math.clamp((state.document_scroll_y - start) / height, 0, 1);
+ state.layout_anchor_pending = true;
+}
+
+fn consumeOutlineReveal(state: *State, viewport: Viewport) void {
+ const destination = state.outline_reveal_pending orelse return;
+ state.outline_reveal_pending = null;
+ const page = @min(destination.page, state.page_count -| 1);
+ const size = state.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;
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) +
+ @as(f64, y) / @as(f64, size.height) * @as(f64, @floatFromInt(state.page_heights[page]));
+
+ if (destination.x) |raw_x| if (state.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);
+ state.pan_x = @intFromFloat(@round(
+ wanted / overflow * @as(f64, std.math.maxInt(u16)),
+ ));
+ }
+ };
+ state.search_reveal_pending = false;
+ state.reveal_viewport_w = viewport.pixel_w;
+ state.reveal_viewport_h = viewport.pixel_h;
+ state.reveal_fit = state.fit;
+ state.reveal_viewport_valid = true;
+}
+
+pub fn ensureLayout(state: *State, viewport: Viewport) void {
+ if (comptime !enabled) return;
+ const tz = tracy.zone(@src(), "pdf.ensure_layout");
+ defer tz.end();
+ if (state.layout_valid and !state.scroll_to_page_pending and
+ !state.layout_anchor_pending and
+ (state.layout_viewport_w != viewport.pixel_w or
+ state.layout_viewport_h != viewport.pixel_h)) captureLayoutAnchor(state);
+ if (!state.layout_valid or state.layout_viewport_w != viewport.pixel_w or
+ state.layout_viewport_h != viewport.pixel_h or state.layout_fit != state.fit)
+ {
+ var at: u64 = 0;
+ for (state.page_sizes, 0..) |size, page| {
+ state.page_starts[page] = at;
+ const height = pageHeight(size, viewport, state.fit);
+ state.page_heights[page] = height;
+ at = std.math.add(u64, at, height) catch std.math.maxInt(u64);
+ if (page + 1 < state.page_count)
+ at = std.math.add(u64, at, page_gap_px) catch std.math.maxInt(u64);
+ }
+ state.document_height = at;
+ state.layout_viewport_w = viewport.pixel_w;
+ state.layout_viewport_h = viewport.pixel_h;
+ state.layout_fit = state.fit;
+ state.layout_valid = true;
+ if (state.scroll_to_page_pending) {
+ state.document_scroll_y = @floatFromInt(state.page_starts[state.page]);
+ state.scroll_to_page_pending = false;
+ state.layout_anchor_pending = false;
+ } else if (state.layout_anchor_pending) {
+ const page = @min(state.layout_anchor_page, state.page_count -| 1);
+ state.document_scroll_y = @as(f64, @floatFromInt(state.page_starts[page])) +
+ state.layout_anchor_fraction * @as(f64, @floatFromInt(state.page_heights[page]));
+ state.layout_anchor_pending = false;
+ }
+ }
+ consumeOutlineReveal(state, viewport);
+ const max_scroll = @as(f64, @floatFromInt(state.document_height -| viewport.pixel_h));
+ state.document_scroll_y = std.math.clamp(state.document_scroll_y, 0, max_scroll);
+}
+
+pub const WordProbe = if (enabled) struct {
+ page: usize,
+ quads: []pdf.Quad,
+ text: []u8,
+ link: ?pdf.Link = null,
+
+ pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void {
+ if (probe.link) |*link| link.deinit(gpa);
+ gpa.free(probe.quads);
+ gpa.free(probe.text);
+ probe.* = undefined;
+ }
+} else void;
+
+pub const PointerDrag = if (enabled) struct {
+ native: bool = false,
+ /// Right-button Look probes this cell on release without borrowing or
+ /// replacing the pane's persistent MuPDF selection.
+ word_at: ?struct { col: u16, row: u16 } = null,
+ /// Drag updates keep selection geometry/text live, but their expensive
+ /// baked raster highlight is committed once on release.
+ selection_changed: bool = false,
+} else struct {};
+
+pub const PointerAction = enum { look, exec };
+
+pub const PointerRelease = if (enabled) struct {
+ action: ?PointerAction = null,
+ text: []const u8 = &.{},
+ probe: ?WordProbe = null,
+
+ pub fn deinit(release: *@This(), gpa: std.mem.Allocator) void {
+ if (release.probe) |*probe| probe.deinit(gpa);
+ release.* = undefined;
+ }
+} else struct {
+ pub fn deinit(_: *@This(), _: std.mem.Allocator) void {}
+};
+
+pub fn probeWord(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ point: if (enabled) pdf.Point else void,
+) !?WordProbe {
+ if (comptime !enabled) return null;
+ var selection = try document.select(gpa, page, point, point);
+ errdefer selection.deinit(gpa);
+ const text = try document.copySelection(gpa, page, selection.start, selection.end);
+ errdefer gpa.free(text);
+ if (selection.quads.len == 0 or text.len == 0) {
+ selection.deinit(gpa);
+ gpa.free(text);
+ return null;
+ }
+ // Transfer the quad allocation out of Selection. There is intentionally
+ // no Selection.deinit on this success path: WordProbe is now its owner.
+ return .{ .page = page, .quads = selection.quads, .text = text };
+}
+
+pub fn pointAtGeometry(
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ full_width: usize,
+ full_height: usize,
+ band_y: usize,
+ pixel_x: i64,
+ pixel_y: i64,
+ clamp_to_page: bool,
+) ?Point {
+ if (comptime !enabled) return null;
+ if (full_width == 0 or full_height == 0 or
+ geometry.src.w == 0 or geometry.src.h == 0 or
+ geometry.dst.w == 0 or geometry.dst.h == 0) return null;
+
+ const left: f64 = @floatFromInt(geometry.dst.x);
+ const top: f64 = @floatFromInt(geometry.dst.y);
+ const right = left + @as(f64, @floatFromInt(geometry.dst.w));
+ const bottom = top + @as(f64, @floatFromInt(geometry.dst.h));
+ var x: f64 = @floatFromInt(pixel_x);
+ var y: f64 = @as(f64, @floatFromInt(pixel_y)) - @as(f64, pixel_offset_y);
+ if (clamp_to_page) {
+ // Half-open rectangles: keep a clamped point infinitesimally inside
+ // the last presented pixel instead of letting it become `right`.
+ const epsilon = 1.0 / 1024.0;
+ x = std.math.clamp(x, left, @max(left, right - epsilon));
+ y = std.math.clamp(y, top, @max(top, bottom - epsilon));
+ } else if (x < left or x >= right or y < top or y >= bottom) {
+ return null;
+ }
+
+ const source_x_f = @as(f64, @floatFromInt(geometry.src.x)) +
+ (x - left) * @as(f64, @floatFromInt(geometry.src.w)) /
+ @as(f64, @floatFromInt(geometry.dst.w));
+ const source_y_f = @as(f64, @floatFromInt(geometry.src.y)) +
+ (y - top) * @as(f64, @floatFromInt(geometry.src.h)) /
+ @as(f64, @floatFromInt(geometry.dst.h));
+ const source_x: usize = @min(full_width - 1, @as(usize, @intFromFloat(@floor(source_x_f))));
+ const band_source_y: usize = @intFromFloat(@floor(source_y_f));
+ const source_y = @min(full_height - 1, band_y + band_source_y);
+ return .{
+ .x = (@as(f32, @floatFromInt(source_x)) + 0.5) /
+ @as(f32, @floatFromInt(full_width)),
+ .y = (@as(f32, @floatFromInt(source_y)) + 0.5) /
+ @as(f32, @floatFromInt(full_height)),
+ };
+}
+
+pub fn openPane(
+ core: *pardes.Pardes,
+ id: usize,
+ path: []const u8,
+ page_one_based: usize,
+) !*pardes.Pane {
+ if (comptime !enabled) return error.PdfDisabled;
+ // The slot stays ours across the read: another request may make a
+ // pane meanwhile.
+ core.reserved_slots[id] = true;
+ defer core.reserved_slots[id] = false;
+ var state = if (filesystem.localPath(path) != null)
+ try State.open(core.pdf_gpa, path, page_one_based)
+ else virtual: {
+ const bytes = try filesystem.read(core, path);
+ defer core.gpa.free(bytes);
+ break :virtual try State.openBytes(core.pdf_gpa, path, bytes, page_one_based);
+ };
+ errdefer state.deinit(core.pdf_gpa);
+ const pane = try core.newDocPane(id);
+ pane.pdf = state;
+ pane.cur_pinned = true;
+ if (filesystem.localPath(path) != null) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+ return pane;
+}
+
+/// Release a PDF payload while its pane slot is still installed, so the host
+/// can retire the corresponding directory watch before that id is reused.
+pub fn deinitPane(core: *pardes.Pardes, pane: *pardes.Pane, state: *State) void {
+ if (comptime !enabled) return;
+ for (core.panes, 0..) |slot, id| {
+ if (slot == pane) core.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ }
+ state.deinit(core.pdf_gpa);
+}
+
+pub fn reloadPane(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+) !void {
+ if (comptime !enabled) return error.PdfDisabled;
+ const state = &(pane.pdf orelse return error.MissingPdfState);
+ try state.reload(core.pdf_gpa);
+ resetPageChrome(pane);
+}
+
+pub fn isSectionsOutput(pane: *const pardes.Pane) bool {
+ if (comptime !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,
+ };
+}
+
+fn refreshCleanSections(core: *pardes.Pardes, state: *State) void {
+ if (comptime !enabled) return;
+ const remembered = state.sections_output orelse return;
+ if (remembered.pane >= core.panes.len) return;
+ const result = core.panes[remembered.pane] orelse return;
+ if (result.serial != remembered.serial or !isSectionsOutput(result)) return;
+ const file = &result.file.?;
+ if (file.revision != remembered.revision) return;
+
+ const content = state.renderSections(core.pdf_gpa, core.gpa) catch {
+ state.sections_output = null;
+ return;
+ };
+ if (std.mem.eql(u8, file.content, content)) {
+ core.gpa.free(content);
+ return;
+ }
+ core.invalidateLookHover(remembered.pane);
+ File.setContent(core, file, content);
+ const rows = File.lineCount(file.content);
+ file.scroll = @min(file.scroll, rows - 1);
+ const row = @min(@as(usize, @intCast(@max(0, result.cur_row))), rows - 1);
+ result.cur_row = @intCast(row);
+ result.cur_col = @intCast(@min(
+ @as(usize, @intCast(@max(0, result.cur_col))),
+ modal.lineSlice(file.content, row).len,
+ ));
+ result.msel.active = false;
+ result.vsel.active = false;
+ result.nsel = 0;
+ state.sections_output.?.revision = file.revision;
+}
+
+/// One successful watched-path transaction, including every piece of derived
+/// PDF output. The caller owns generic update bookkeeping and status text.
+pub fn reloadWatched(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+) !void {
+ try reloadPane(core, pane);
+ refreshCleanSections(core, &pane.pdf.?);
+}
+
+fn rearmCleanSections(
+ core: *pardes.Pardes,
+ id: usize,
+ pane: *pardes.Pane,
+ state: *State,
+) bool {
+ const remembered = state.sections_output orelse return false;
+ if (remembered.pane >= core.panes.len) return false;
+ const result = core.panes[remembered.pane] orelse return false;
+ if (result.serial != remembered.serial or !isSectionsOutput(result)) return false;
+ const file = &result.file.?;
+ if (file.revision != remembered.revision) return false;
+
+ 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;
+ core.armLookWalk(remembered.pane);
+ core.active = id;
+ return true;
+}
+
+/// Lazily materialise this pane's cached outline as a location list. All
+/// PDF-specific ownership stays here; Pardes contributes only placement.
+pub fn openSections(core: *pardes.Pardes, id: usize) void {
+ if (comptime !enabled) return;
+ const pane = core.panes[id] orelse return;
+ const state = &(pane.pdf orelse return);
+ if (rearmCleanSections(core, id, pane, state)) return;
+ const content = state.renderSections(core.pdf_gpa, core.gpa) catch return;
+
+ const free = core.freeSlot() orelse {
+ core.gpa.free(content);
+ return;
+ };
+ const dir = std.fs.path.dirname(state.path) orelse "/";
+ const result = Output.open(
+ core,
+ free,
+ dir,
+ .{ .cmd = .PdfSections },
+ "",
+ content,
+ ) catch {
+ core.gpa.free(content);
+ return;
+ };
+ state.sections_output = .{
+ .pane = free,
+ .serial = result.serial,
+ .revision = result.file.?.revision,
+ };
+ core.placeDoc(id, free, result);
+ layout.compute(core);
+ core.active = id;
+ pane.search_pane = free;
+ pane.search_row = null;
+ core.armLookWalk(free);
+}
+
+const SectionsOwner = struct {
+ id: usize,
+ pane: *pardes.Pane,
+ state: *State,
+};
+
+fn sectionsOwner(core: *pardes.Pardes, output_id: usize) ?SectionsOwner {
+ const output = core.panes[output_id] orelse return null;
+ if (!isSectionsOutput(output)) return null;
+ const file = output.file.?;
+ for (core.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const state = if (pane.pdf) |*pdf_state| pdf_state else continue;
+ const token = state.sections_output orelse continue;
+ if (token.pane == output_id and token.serial == output.serial and
+ token.revision == file.revision)
+ return .{ .id = id, .pane = pane, .state = state };
+ }
+ return null;
+}
+
+pub fn lookSection(
+ core: *pardes.Pardes,
+ output_id: usize,
+ path: []const u8,
+ at: look.Spot,
+) bool {
+ if (comptime !enabled) return false;
+ const output = core.panes[output_id] orelse return false;
+ if (!isSectionsOutput(output)) return false;
+ if (at.line == 0 or at.col == 0 or at.end_line != 0 or
+ !look.isPdfPath(path)) return false;
+ const owner = sectionsOwner(core, output_id) orelse return true;
+
+ var joined_path: [4096]u8 = undefined;
+ const output_dir = std.fs.path.dirname(output.file.?.path) orelse "/";
+ const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/";
+ const target_path = if (std.fs.path.isAbsolute(path))
+ path
+ else
+ std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true;
+ if (!std.mem.eql(u8, owner.state.path, target_path)) return true;
+
+ const destination = owner.state.sectionDestination(core.pdf_gpa, at.col - 1) orelse return true;
+ switch (destination) {
+ .internal => |internal| {
+ core.clearNavigationSelection(owner.pane);
+ revealOutlineDestination(core, owner.pane, internal);
+ },
+ .external => |uri| if (uri.len <= 256) core.emit(.{ .open_link = .from(uri) }),
+ .none => unreachable,
+ }
+ core.active = owner.id;
+ return true;
+}
+
+pub fn resetPageChrome(pane: *pardes.Pane) void {
+ pane.cur_row = 0;
+ pane.cur_col = 0;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.nsel = 0;
+}
+
+pub fn activatePage(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ page: usize,
+ reveal: bool,
+) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ if (state.activatePage(core.pdf_gpa, page, reveal)) resetPageChrome(pane);
+}
+
+pub fn revealOutlineDestination(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ destination: OutlineInternalDestination,
+) void {
+ if (comptime !enabled) return;
+ activatePage(core, pane, destination.page, false);
+ const state = &pane.pdf.?;
+ state.queueOutlineReveal(destination);
+ // Consume immediately when geometry already exists; otherwise the PDF
+ // draw pass consumes the same value after the next layout transaction.
+ _ = ensurePaneLayout(core, pane);
+}
+
+pub fn setPage(core: *pardes.Pardes, pane: *pardes.Pane, page: usize) void {
+ activatePage(core, pane, page, true);
+}
+
+pub fn toggleFit(pane: *pardes.Pane) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ state.toggleFit();
+}
+
+pub fn toggleTint(pane: *pardes.Pane) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ state.toggleTint();
+}
+
+pub fn stepPanePage(core: *pardes.Pardes, pane: *pardes.Pane, delta: i64) void {
+ if (comptime !enabled) return;
+ const state = &(pane.pdf orelse return);
+ if (stepPage(state, core.pdf_gpa, delta)) resetPageChrome(pane);
+}
+
+pub fn paneViewport(core: *const pardes.Pardes, pane: *const pardes.Pane) ?Viewport {
+ if (comptime !enabled) return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const cols = rect.w -| config.GUTTER;
+ const rows = rect.h -| pardes.BOX_H;
+ if (cols == 0 or rows == 0) return null;
+ return .{
+ .pixel_w = @as(u32, cols) * @as(u32, core.cell_pixels.w),
+ .pixel_h = @as(u32, rows) * @as(u32, core.cell_pixels.h),
+ };
+}
+
+pub fn ensurePaneLayout(core: *pardes.Pardes, pane: *pardes.Pane) ?Viewport {
+ if (comptime !enabled) return null;
+ const state = &(pane.pdf orelse return null);
+ const view = paneViewport(core, pane) orelse return null;
+ ensureLayout(state, view);
+ return view;
+}
+
+pub fn tintColors(core: *const pardes.Pardes) TintColors {
+ if (comptime !enabled) return;
+ const theme = core.theme();
+ return .{
+ .background = theme.bg orelse theme.tag_bg,
+ .foreground = theme.fg orelse theme.tag_fg,
+ };
+}
+
+pub fn paneGeometry(core: *const pardes.Pardes, pane: *const pardes.Pane) ?image.NativeGeometry {
+ if (comptime !enabled) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const view = paneViewport(core, pane) orelse return null;
+ return activeGeometry(state, view);
+}
+
+pub fn pageAtGridRow(core: *const pardes.Pardes, pane: *const pardes.Pane, row: u16) ?usize {
+ if (comptime !enabled) return null;
+ const state = pane.pdf orelse return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const body_y = core.bodyTop(rect);
+ if (row < body_y or row >= body_y + (rect.h -| pardes.BOX_H)) return null;
+ const local_y = @as(f64, @floatFromInt(
+ @as(u32, row - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2,
+ ));
+ return pageAtViewportY(&state, local_y);
+}
+
+pub fn paneNativeReady(core: *const pardes.Pardes, pane: *const pardes.Pane) bool {
+ if (comptime !enabled) return false;
+ if (!core.native_images) return false;
+ const state = if (pane.pdf) |*view| view else return false;
+ const view = paneViewport(core, pane) orelse return false;
+ return nativeReady(state, view);
+}
+
+pub fn nativePageAtGridRow(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ row: u16,
+) ?usize {
+ if (comptime !enabled) return null;
+ if (!core.native_images) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const page = pageAtGridRow(core, pane, row) orelse return null;
+ const view = paneViewport(core, pane) orelse return null;
+ if (!pageReady(state, view, page)) return null;
+ return page;
+}
+
+pub fn pointAt(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ col: u16,
+ row: u16,
+ clamp_to_page: bool,
+) ?Point {
+ if (comptime !enabled) return null;
+ const state = pane.pdf orelse return null;
+ return panePointAtPage(core, pane, state.page, col, row, clamp_to_page);
+}
+
+pub fn panePointAtPage(
+ core: *const pardes.Pardes,
+ pane: *const pardes.Pane,
+ page: usize,
+ col: u16,
+ row: u16,
+ clamp_to_page: bool,
+) ?Point {
+ if (comptime !enabled) return null;
+ const state = if (pane.pdf) |*view| view else return null;
+ const view = paneViewport(core, pane) orelse return null;
+ const rect = for (core.panes, 0..) |slot, id| {
+ if (slot == pane) break core.rects[id];
+ } else return null;
+ const body_x = @as(i64, rect.x + config.GUTTER);
+ const body_y = @as(i64, core.bodyTop(rect));
+ const px = (@as(i64, col) - body_x) * core.cell_pixels.w + core.cell_pixels.w / 2;
+ const py = (@as(i64, row) - body_y) * core.cell_pixels.h + core.cell_pixels.h / 2;
+ return pointAtPage(state, view, page, px, py, clamp_to_page);
+}
+
+pub fn probeAt(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+) ?WordProbe {
+ if (comptime !enabled) return null;
+ const page = nativePageAtGridRow(core, pane, row) orelse return null;
+ const point = panePointAtPage(core, pane, page, col, row, false) orelse return null;
+ const state = &(pane.pdf orelse return null);
+ if (state.document.linkAt(core.pdf_gpa, page, point) catch null) |found| {
+ var link = found;
+ if (core.pdfLinkLocation(pane, link) != null) {
+ const quads = core.pdf_gpa.alloc(pdf.Quad, 1) catch {
+ link.deinit(core.pdf_gpa);
+ return null;
+ };
+ const text = state.document.copyRectangle(core.pdf_gpa, page, link.quad) catch {
+ core.pdf_gpa.free(quads);
+ link.deinit(core.pdf_gpa);
+ return null;
+ };
+ quads[0] = link.quad;
+ return .{ .page = page, .quads = quads, .text = text, .link = link };
+ }
+ link.deinit(core.pdf_gpa);
+ }
+ return probeWord(&state.document, core.pdf_gpa, page, point) catch null;
+}
+
+pub fn beginDrag(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ snap_word: bool,
+) bool {
+ if (comptime !enabled) return false;
+ const hit_page = pageAtGridRow(core, pane, row) orelse return false;
+ if (hit_page != pane.pdf.?.page) activatePage(core, pane, hit_page, false);
+ const state = &pane.pdf.?;
+ state.clearDrag();
+ const point = pointAt(core, pane, col, row, false) orelse return false;
+ state.drag_anchor = point;
+ state.drag_head = point;
+ if (!snap_word) return true;
+ if (state.selection_page == state.page) if (state.selection) |selection| if (selection.contains(point)) return true;
+ if (state.setSelection(core.pdf_gpa, point, point, true) == .failed) {
+ // Preserve the old selection transactionally, but never let an
+ // outside click execute its stale text.
+ state.clearDrag();
+ return false;
+ }
+ return true;
+}
+
+pub fn beginSelection(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+) bool {
+ return beginDrag(core, pane, col, row, true);
+}
+
+pub fn updateSelection(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+ invalidate_raster: bool,
+) SelectionUpdate {
+ if (comptime !enabled) return .failed;
+ const state = &(pane.pdf orelse return .failed);
+ const anchor = state.drag_anchor orelse return .failed;
+ const point = pointAt(core, pane, col, row, true) orelse return .failed;
+ if (state.drag_head) |head| if (head.x == point.x and head.y == point.y) return .stationary;
+ const result = state.setSelection(core.pdf_gpa, anchor, point, invalidate_raster);
+ if (result != .failed) state.drag_head = point;
+ return result;
+}
+
+pub fn pointerStart(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ button: pardes.Mouse.Button,
+ col: u16,
+ row: u16,
+ on_tag: bool,
+) PointerDrag {
+ if (comptime !enabled) return .{};
+ if (on_tag or !paneNativeReady(core, pane)) return .{};
+ var drag: PointerDrag = .{ .native = true };
+ if (button == config.look_button) {
+ drag.word_at = .{ .col = col, .row = row };
+ } else if (button == config.select_button) {
+ _ = beginDrag(core, pane, col, row, false);
+ } else if (button == config.exec_button) {
+ _ = beginDrag(core, pane, col, row, true);
+ }
+ return drag;
+}
+
+pub fn pointerUpdate(
+ drag: *PointerDrag,
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ col: u16,
+ row: u16,
+) void {
+ if (comptime !enabled) return;
+ if (!drag.native) return;
+ if (drag.word_at) |at| {
+ if (col == at.col and row == at.row) return;
+ if (!beginDrag(core, pane, at.col, at.row, false)) {
+ drag.word_at = null;
+ return;
+ }
+ switch (updateSelection(core, pane, col, row, false)) {
+ .failed => {
+ drag.word_at = null;
+ pane.pdf.?.clearDrag();
+ },
+ // Adjacent grid cells can still address one raster pixel. Keep
+ // click mode and retry farther out.
+ .stationary => {},
+ .unchanged => drag.word_at = null,
+ .changed => {
+ drag.word_at = null;
+ drag.selection_changed = true;
+ },
+ }
+ return;
+ }
+ switch (updateSelection(core, pane, col, row, false)) {
+ .failed => pane.pdf.?.clearDrag(),
+ .stationary, .unchanged => {},
+ .changed => drag.selection_changed = true,
+ }
+}
+
+pub fn pointerCancel(pane: *pardes.Pane, drag: PointerDrag) void {
+ if (comptime !enabled) return;
+ if (drag.native) if (pane.pdf) |*state| {
+ if (drag.selection_changed) state.invalidateRaster(state.page);
+ state.clearDrag();
+ };
+}
+
+pub fn pointerRelease(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ drag: PointerDrag,
+ button: pardes.Mouse.Button,
+ chorded: bool,
+) PointerRelease {
+ if (comptime !enabled) return .{};
+ const state = &(pane.pdf orelse return .{});
+ const slot = @intFromEnum(button);
+ const grid_selection = pane.sel[slot];
+ pane.sel[slot].state = .none; // native quads, not projected text cells
+
+ // Drag updates changed live geometry/text while leaving baked pixels
+ // stable. Commit exactly once before any chord/Exec/Look can clear state.
+ if (drag.selection_changed) state.invalidateRaster(state.page);
+ if (chorded) {
+ state.clearDrag();
+ return .{};
+ }
+ if (drag.word_at) |at| {
+ const maybe_probe = probeAt(core, pane, at.col, at.row);
+ state.clearDrag();
+ const probe = maybe_probe orelse return .{};
+ // A neighboring visible page becomes current before Look dispatch.
+ // WordProbe owns its text/quads independently of that state change.
+ if (probe.page != state.page) activatePage(core, pane, probe.page, false);
+ const text = probe.text;
+ return .{ .action = .look, .text = text, .probe = probe };
+ }
+ if (button == config.select_button) {
+ const dragged = grid_selection.c0 != grid_selection.c1 or
+ grid_selection.r0 != grid_selection.r1;
+ if (!dragged) state.clearSelection(core.pdf_gpa);
+ pane.cur_pinned = true;
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.normal.clear();
+ pane.nsel = 0;
+ state.clearDrag();
+ return .{};
+ }
+ if (state.drag_anchor == null or state.selection_text.len == 0) {
+ state.clearDrag();
+ return .{};
+ }
+ const text = state.selection_text;
+ state.clearDrag();
+ return .{
+ .action = if (button == config.look_button) .look else .exec,
+ .text = text,
+ };
+}
+
+pub fn highlightInput(
+ core: *pardes.Pardes,
+ pane_id: usize,
+ pane: *const pardes.Pane,
+) HighlightInput {
+ if (comptime !enabled) return;
+ const hover_quads: []const Quad = if (core.pdf_hover_preview) |preview|
+ if (preview.pane == pane_id and preview.serial == pane.serial)
+ preview.probe.quads
+ else
+ &.{}
+ else
+ &.{};
+ const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null;
+ const link_hover = hover_quads.len > 0 and core.pdf_hover_preview.?.probe.link != null;
+ const selection_color = core.theme().sel_bg;
+ return .{
+ .hover_quads = hover_quads,
+ .hover_page = hover_page,
+ .hover_color = if (link_hover) core.theme().box else selection_color,
+ .selection_color = selection_color,
+ .live = core.native_image_patches,
+ };
+}
+
+pub fn panPane(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ axis: PanAxis,
+ direction: i8,
+ display_pixels: u32,
+) PanResult {
+ if (comptime !enabled) return .unavailable;
+ const placed = paneGeometry(core, pane) orelse return .unavailable;
+ const state = &(pane.pdf orelse return .unavailable);
+ return panPixels(state, placed, axis, direction, display_pixels);
+}
+
+pub fn scrollPane(core: *pardes.Pardes, pane: *pardes.Pane, delta_pixels: f64) bool {
+ if (comptime !enabled) return false;
+ const tz = tracy.zone(@src(), "pdf.scroll_notch");
+ defer tz.end();
+ const state = &(pane.pdf orelse return false);
+ const view = ensurePaneLayout(core, pane) orelse return false;
+ const result = scrollDocument(state, view, delta_pixels) orelse return false;
+ if (result.active_page != state.page) activatePage(core, pane, result.active_page, false);
+ return true;
+}
+
+pub fn verticalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void {
+ if (comptime !enabled) return;
+ if (!core.native_images) return stepPanePage(core, pane, direction);
+ const rows: u32 = @intCast(@max(1, config.wheel_rows));
+ const pixels = scaledStep(core.cell_pixels.h, rows);
+ _ = scrollPane(core, pane, @as(f64, @floatFromInt(pixels)) * direction);
+}
+
+pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) void {
+ if (comptime !enabled) return;
+ const state = pane.pdf orelse return;
+ if (!core.native_images or state.fit != .height) return;
+ const cols: u32 = @intCast(@max(1, config.wheel_cols));
+ _ = panPane(core, pane, .horizontal, direction, scaledStep(core.cell_pixels.w, cols));
+}
+
+pub fn draw(
+ core: *pardes.Pardes,
+ pane: *pardes.Pane,
+ rect: pardes.Rect,
+ pane_id: usize,
+ text_x: u16,
+ text_width: u16,
+) bool {
+ if (comptime !enabled) return false;
+ if (!core.native_images or rect.h <= pardes.BOX_H) return false;
+ const state = &(pane.pdf orelse return false);
+ const view = paneViewport(core, pane) orelse return false;
+ const key = TintKey{ .mode = state.tint, .colors = tintColors(core) };
+ const visible = renderFrame(
+ state,
+ core.pdf_gpa,
+ core.scratch.allocator(),
+ view,
+ pardes.pdf_raster_policy,
+ key,
+ highlightInput(core, pane_id, pane),
+ );
+ var placed_any = false;
+ var page = visible.first;
+ const visible_end = visible.first + visible.len;
+ while (page < visible_end) : (page += 1) {
+ const placed = placedRaster(state, view, page) orelse continue;
+ const slot = rasterForPage(state, page).?;
+ const patch: ?pardes.ImagePatch = if (slot.patch_from != 0 and
+ slot.patch_rows.start < slot.patch_rows.end)
+ .{
+ .from = slot.patch_from,
+ .y = @intCast(slot.patch_rows.start - slot.band_y),
+ .h = @intCast(slot.patch_rows.end - slot.patch_rows.start),
+ }
+ else
+ null;
+ if (!core.appendImagePlace(.{
+ .pane = @intCast(pane_id),
+ .serial = pane.serial,
+ .native = .{
+ .revision = placed.revision,
+ .page = @intCast(placed.page),
+ .fit = switch (placed.fit) {
+ .width => .width,
+ .height => .height,
+ },
+ .pan_x = placed.pan_x,
+ .geometry = placed.geometry,
+ .pixel_offset_y = placed.pixel_offset_y,
+ .paper_bg = if (pardes.platform == .macos)
+ (if (key.mode == .disabled) .{ 255, 255, 255 } else key.colors.background)
+ else
+ null,
+ },
+ .x = text_x,
+ // Below the notice chips, like an image: a placed page is
+ // drawn after the cells and would paint a chip out.
+ .y = core.bodyTop(rect) + pane.notices.span(),
+ .w = text_width,
+ .h = (rect.h -| pardes.BOX_H) -| pane.notices.span(),
+ .rgba = placed.rgba,
+ .iw = placed.width,
+ // The texture contains the retained band, not the full page.
+ .ih = placed.band_height,
+ .patch = patch,
+ })) break;
+ // A shell now holds (or will fetch whole) this revision.
+ slot.patch_from = slot.revision;
+ slot.patch_rows = .{ .start = 0, .end = 0 };
+ placed_any = true;
+ }
+ if (!placed_any) return false;
+
+ // This frame has spent the motion used to choose its raster band.
+ state.scroll_travel = 0;
+ const body_y = core.bodyTop(rect);
+ const body_h = rect.h -| pardes.BOX_H;
+ const chrome = core.chromeTheme();
+ const theme = core.theme();
+ const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default;
+ // The rail runs past the notice bands; only the thumb tracks the text.
+ const rail_y = if (core.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ core.surface.fill(rect.x, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
+ core.surface.fill(rect.x + 1, rail_y, 1, rect.h -| pardes.BOX_H, .{ .bg = pane_bg });
+
+ const track_h: usize = body_h;
+ const total = @max(@as(u64, 1), state.document_height);
+ const len = @max(
+ @as(usize, 1),
+ @as(usize, @intCast(@min(
+ @as(u64, track_h),
+ @as(u64, track_h) * view.pixel_h / total,
+ ))),
+ );
+ const offset: u64 = @intFromFloat(@floor(state.document_scroll_y));
+ const pos: usize = @intCast(@min(
+ @as(u64, track_h -| 1),
+ @as(u64, track_h) * offset / total,
+ ));
+ var y = pos;
+ while (y < track_h and y < pos + len) : (y += 1)
+ core.surface.fill(
+ rect.x,
+ body_y + @as(u16, @intCast(y)),
+ 1,
+ 1,
+ .{ .bg = .{ .rgb = chrome.scroll_thumb } },
+ );
+ return true;
+}
+
+pub const SectionRows = if (enabled) struct {
+ pub fn usableDestination(destination: pdf.OutlineDestination) ?pdf.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;
+ }
+
+ pub fn resolve(entries: []const pdf.OutlineEntry, ordinal: usize) ?pdf.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;
+ }
+
+ pub fn resolveOrdinals(
+ gpa: std.mem.Allocator,
+ entries: []const pdf.OutlineEntry,
+ ) ![]usize {
+ const unresolved = std.math.maxInt(usize);
+ const ordinals = try gpa.alloc(usize, entries.len);
+ @memset(ordinals, unresolved);
+
+ const Pending = struct { depth: u8, ordinal: usize };
+ var pending: [256]Pending = undefined;
+ var pending_len: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth)
+ pending_len -= 1;
+
+ if (usableDestination(entry.destination) != null) {
+ ordinals[ordinal] = ordinal;
+ for (pending[0..pending_len]) |ancestor|
+ ordinals[ancestor.ordinal] = ordinal;
+ pending_len = 0;
+ } else if (entry.destination == .none) {
+ pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal };
+ pending_len += 1;
+ }
+ }
+ return ordinals;
+ }
+
+ fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize {
+ const raw = title orelse {
+ if (out) |buf| @memcpy(buf[0..10], "[untitled]");
+ return 10;
+ };
+ var at: usize = 0;
+ 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) {
+ if (out) |buf| buf[at] = ' ';
+ at += 1;
+ }
+ if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]);
+ at += n;
+ wrote = true;
+ pending_space = false;
+ }
+ i += n;
+ }
+ if (!wrote) {
+ if (out) |buf| @memcpy(buf[0..13], "[empty title]");
+ return 13;
+ }
+ return at;
+ }
+
+ pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf.OutlineEntry) ![]u8 {
+ const target = std.fs.path.basename(path);
+ const ordinals = try resolveOrdinals(gpa, entries);
+ defer gpa.free(ordinals);
+ var total: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ const resolved = ordinals[ordinal];
+ if (resolved == std.math.maxInt(usize)) continue;
+ const destination = usableDestination(entries[resolved].destination) orelse unreachable;
+ total += switch (destination) {
+ .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
+ .external => |uri| std.fmt.count("{s} ", .{uri}),
+ .none => unreachable,
+ };
+ total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1;
+ }
+ const out = try gpa.alloc(u8, total);
+ errdefer gpa.free(out);
+ var at: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ const resolved = ordinals[ordinal];
+ if (resolved == std.math.maxInt(usize)) continue;
+ const destination = usableDestination(entries[resolved].destination) orelse unreachable;
+ const prefix = switch (destination) {
+ .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
+ .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}),
+ .none => unreachable,
+ };
+ at += prefix.len;
+ const indent = @as(usize, entry.depth) * 2;
+ @memset(out[at..][0..indent], ' ');
+ at += indent;
+ at += cleanTitle(out[at..], entry.title);
+ out[at] = '\n';
+ at += 1;
+ }
+ return out;
+ }
+} else struct {};