//! 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 exec = @import("exec.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 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.body.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; } look.invalidateLookHover(core, 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.body.cur_row))), rows - 1); result.body.cur_row = @intCast(row); result.body.cur_col = @intCast(@min( @as(usize, @intCast(@max(0, result.body.cur_col))), modal.lineSlice(file.content, row).len, )); result.body.msel.active = false; result.body.vsel.active = false; result.body.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.body.cur_row = 0; result.body.cur_col = 0; result.body.msel.active = false; result.body.vsel.active = false; result.body.nsel = 0; pane.search_pane = remembered.pane; pane.search_row = null; look.armLookWalk(core, 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, }; exec.placeNew(core, id, id, free, .doc); layout.compute(core); core.active = id; pane.search_pane = free; pane.search_row = null; look.armLookWalk(core, 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| { look.clearNavigationSelection(core, 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.body.cur_row = 0; pane.body.cur_col = 0; pane.body.vsel.active = false; pane.body.msel.active = false; pane.body.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 -| pane.tag_rows; 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(pane, rect); if (row < body_y or row >= body_y + (rect.h -| pane.tag_rows)) 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(pane, 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 (look.pdfLinkLocation(core, 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.body.cur_pinned = true; pane.body.mode = .normal; pane.body.msel.active = false; pane.body.vsel.active = false; pane.body.normal.clear(); pane.body.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, s: *pardes.Surface, pane: *pardes.Pane, rect: pardes.Rect, pane_id: usize, text_x: u16, text_width: u16, rail: *pardes.Region, ) bool { if (comptime !enabled) return false; if (!core.native_images or rect.h <= pane.tag_rows) 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(pane, rect) + pane.notices.span(), .w = text_width, .h = (rect.h -| pane.tag_rows) -| 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 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, // and it is measured here, in document pixels, for `place`'s region. const track_h: usize = rail.rect.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, )); rail.thumb_y = @intCast(pos); rail.thumb_h = @intCast(@min(len, track_h -| pos)); s.fill(rail.rect.x, rail.rect.y, 1, rail.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(rail.rect.x + 1, rail.rect.y, 1, rail.rect.h, .{ .bg = pane_bg }); s.fill(rail.rect.x, rail.rect.y + rail.thumb_y, 1, rail.thumb_h, .{ .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; // unreachable: resolveOrdinals resolves only to usable destinations 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; // unreachable: resolveOrdinals resolves only to usable destinations 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 {};