From 0ce8269c251eaba188883c38dd3e97be9fb362cc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 21:30:25 -0300 Subject: Move the Pdf namespace out of panes.zig into pdf_view.zig Pure move: the body of `pub const Pdf = struct { ... }` is now the file pdf_view.zig, dedented, with the imports it uses; panes.zig keeps `pub const Pdf = @import("pdf_view.zig");`, so `panes.Pdf.x` callers are unchanged. The file is not Pdf.zig because pdf.zig (the MuPDF module) sits beside it and the two names are one file on a case-insensitive disk. pdf_view.zig joins the embedded source list. Co-Authored-By: Claude Opus 5.5 --- src/fs.zig | 1 + src/panes.zig | 2790 +---------------------------------------------------- src/pdf_view.zig | 2802 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 2804 insertions(+), 2789 deletions(-) create mode 100644 src/pdf_view.zig (limited to 'src') 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 {}; -- cgit v1.3