diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1002 |
1 files changed, 816 insertions, 186 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index f32eb93f..04668f9c 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -79,6 +79,7 @@ test "SDL PDF raster policy is materially denser than Kitty" { // that ARE taste — the gutter, the line-number prefix, scrolloff, the pane // minimums — live in config.zig with everything else a user retargets. pub const MAX_PANES = 16; +pub const PDF_PAGE_GAP_PX: u32 = 8; pub const MAX_COLS = 6; /// how far back the jump stack remembers. Vim keeps 100; this is a session of /// at most sixteen panes, so the depth that matters is "more visits than you @@ -159,43 +160,97 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expectEqual(@as(u32, 0), request.minimum_height); } - // The default reading view moves one visible cell row without replacing - // the page pixels. Fit/pan are placement state, not texture identity. + // SDL's raw dy path retains fractions in the placement itself; it does + // not leave native pixels fixed while only the underlying cells slide. + p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } }); + try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y); + _ = frame.reset(.retain_capacity); + const fractional = try p.render(frame.allocator()); + try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision); + try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y); + pane.pdf.?.document_scroll_y = 0; + + // The default reading view moves one exact display-cell distance without + // replacing page pixels. Document placement, not texture identity, moves. p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); - try std.testing.expect(pane.pdf.?.pan_y > 0); + try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y); try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision); _ = frame.reset(.retain_capacity); const panned = try p.render(frame.allocator()); try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision); - try std.testing.expectEqual(pane.pdf.?.pan_y, panned.images[0].?.native.pan_y); + try std.testing.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y); - const row_pan = pane.pdf.?.pan_y; - pane.pdf.?.pan_y = 0; + const row_scroll = pane.pdf.?.document_scroll_y; + pane.pdf.?.document_scroll_y = 0; p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); - const half_pan = pane.pdf.?.pan_y; - try std.testing.expect(half_pan > row_pan); - pane.pdf.?.pan_y = 0; + const half_scroll = pane.pdf.?.document_scroll_y; + try std.testing.expect(half_scroll > row_scroll); + pane.pdf.?.document_scroll_y = 0; p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); - try std.testing.expect(pane.pdf.?.pan_y >= half_pan); + try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll); try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision); - // Crossing the fit-width bottom enters the next page at its top. Crossing - // back enters the previous one at its bottom, ready to keep reading up. - pane.pdf.?.pan_y = std.math.maxInt(u16); - p.update(.{ .key = .{ .cp = 'j' } }); + // At a page boundary both page rasters coexist, the gap remains uncovered, + // and a row step crosses it without snapping either page to an edge. + const viewport = p.pdfViewport(pane).?; + pane.pdf.?.document_scroll_y = @floatFromInt( + pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2, + ); + _ = frame.reset(.retain_capacity); + const boundary = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 2), boundary.nimages); + try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page); + try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page); + const first_bottom = @as(f32, @floatFromInt( + boundary.images[0].?.native.geometry.?.dst.y + + boundary.images[0].?.native.geometry.?.dst.h, + )) + boundary.images[0].?.native.pixel_offset_y; + const second_top = @as(f32, @floatFromInt( + boundary.images[1].?.native.geometry.?.dst.y, + )) + boundary.images[1].?.native.pixel_offset_y; + const visible_gap = second_top - first_bottom; + try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX)); + try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1)); + const before_boundary_scroll = pane.pdf.?.document_scroll_y; + const page0_revision = boundary.images[0].?.native.revision; + const page1_revision = boundary.images[1].?.native.revision; + + // A scroll can activate the already-cached neighbor before the shell's + // next draw. Publish that page's aliases immediately so a queued click in + // the same input batch cannot use page zero's dimensions. + pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5; + Pardes.syncPdfRasterAliases(&pane.pdf.?, Pardes.pdfRasterForPage(&pane.pdf.?, 0).?); + try std.testing.expectEqual(page0_revision, pane.pdf.?.render_revision); + try std.testing.expect(p.scrollPdfDocument(pane, 1)); try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); - try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + try std.testing.expectEqual(page1_revision, pane.pdf.?.render_revision); + p.activatePdfPage(pane, 0, false); + pane.pdf.?.document_scroll_y = before_boundary_scroll; + + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y); _ = frame.reset(.retain_capacity); const second = try p.render(frame.allocator()); - try std.testing.expectEqual(@as(usize, 1), second.nimages); - try std.testing.expect(second.images[0].?.native.revision != first_place.native.revision); + try std.testing.expectEqual(@as(usize, 2), second.nimages); + try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision); + try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision); p.update(.{ .key = .{ .cp = 'k' } }); - try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); - try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_y); + try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y); + + // Once page zero is wholly outside the viewport, both its owned RGBA and + // backend placement disappear; returning later must allocate a new raster. + p.setPdfPage(pane, 1); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + try std.testing.expect(Pardes.pdfRasterForPage(&pane.pdf.?, 0) == null); + p.setPdfPage(pane, 0); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); // PdfFit exists as a real builtin in this build. It resets placement but - // preserves the current page pixels; fit-height j/k remains page-wise. + // preserves the current page pixels; fit-height j/k remains continuous in + // the same document-pixel coordinate space. const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse return error.MissingPdfFitBuiltin; const revision_before_toggle = pane.pdf.?.render_revision; @@ -220,19 +275,23 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { // A fit-height landscape page exposes horizontal overflow to a horizontal // wheel without rerasterizing. Use synthetic dimensions only for the // geometry check; no frame is drawn while they differ from the buffer. - const saved_iw = pane.pdf.?.iw; - const saved_ih = pane.pdf.?.ih; - pane.pdf.?.iw = 2000; - pane.pdf.?.ih = 500; + const active_raster = Pardes.pdfRasterForPage(&pane.pdf.?, pane.pdf.?.page).?; + const saved_iw = active_raster.iw; + const saved_ih = active_raster.ih; + active_raster.iw = 2000; + active_raster.ih = 500; + Pardes.syncPdfRasterAliases(&pane.pdf.?, active_raster); p.pdfHorizontalWheel(pane, 1); try std.testing.expect(pane.pdf.?.pan_x > 0); try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision); - pane.pdf.?.iw = saved_iw; - pane.pdf.?.ih = saved_ih; + active_raster.iw = saved_iw; + active_raster.ih = saved_ih; + Pardes.syncPdfRasterAliases(&pane.pdf.?, active_raster); pane.pdf.?.pan_x = 0; + const before_height_scroll = pane.pdf.?.document_scroll_y; p.update(.{ .key = .{ .cp = 'j' } }); - try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll); const revision_before_search = pane.pdf.?.render_revision; p.runSearch(0, "Pardes", .text, .top); @@ -262,6 +321,156 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page); } +test "PDF continuous strip renders every intersecting short page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + try std.testing.expect(pv.page_count > 3); + p.native_images = true; + + // A legal wide MediaBox can make more than three pages intersect one + // viewport. Seed tiny matching rasters so this tests transport/cache + // cardinality without spending the unit test rendering fake page sizes. + for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 }; + pv.layout_valid = false; + pv.scroll_to_page_pending = true; + const viewport = p.ensurePdfLayout(pane, pv).?; + const visible = Pardes.pdfVisiblePages(pv, viewport); + try std.testing.expectEqual(pv.page_count, visible.len); + const request = p.pdfRenderRequest(pane).?; + for (0..pv.page_count) |page| { + const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4); + @memset(rgba, @intCast(page)); + try pv.rasters.append(gpa, .{ + .valid = true, + .page = page, + .rgba = rgba, + .iw = viewport.pixel_w, + .ih = 1, + .request = request, + .request_valid = true, + .tried = true, + .revision = @intCast(page + 1), + }); + } + + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const surface = try p.render(frame.allocator()); + try std.testing.expectEqual(pv.page_count, surface.nimages); + try std.testing.expectEqual(pv.page_count, pv.rasters.items.len); + for (surface.images[0..surface.nimages], 0..) |maybe, page| { + const place = maybe orelse return error.MissingShortPdfPage; + try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page); + try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h); + } +} + +test "PDF resize preserves page-relative document position" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + p.setPdfPage(pane, 1); + _ = try p.render(frame.allocator()); + const fraction: f64 = 0.375; + pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + + fraction * @as(f64, @floatFromInt(pv.page_heights[1])); + p.update(.{ .resize = .{ + .cols = 100, + .rows = 24, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expect(!pv.layout_valid); + try std.testing.expect(pv.layout_anchor_pending); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const resized_fraction = (pv.document_scroll_y - + @as(f64, @floatFromInt(pv.page_starts[1]))) / + @as(f64, @floatFromInt(pv.page_heights[1])); + try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001); + try std.testing.expectEqual(@as(usize, 1), pv.page); + + const held = pv.document_scroll_y; + p.update(.{ .resize = .{ + .cols = 100, + .rows = 24, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expectEqual(held, pv.document_scroll_y); + try std.testing.expect(pv.layout_valid); +} + +test "PDF pane geometry change preserves its page-relative position" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + p.setPdfPage(pane, 1); + _ = try p.render(frame.allocator()); + const fraction: f64 = 0.625; + pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) + + fraction * @as(f64, @floatFromInt(pv.page_heights[1])); + const old_width = pv.layout_viewport_w; + + // A divider/split changes rects through computeGeom without emitting a + // shell resize. ensurePdfLayout must anchor against the old valid layout + // before rebuilding it for this wider pane. + p.col_weight[0] = 3; + p.computeGeom(); + try std.testing.expect(p.pdfViewport(pane).?.pixel_w != old_width); + try std.testing.expect(pv.layout_valid); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const changed_fraction = (pv.document_scroll_y - + @as(f64, @floatFromInt(pv.page_starts[1]))) / + @as(f64, @floatFromInt(pv.page_heights[1])); + try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001); + try std.testing.expectEqual(@as(usize, 1), pv.page); + + // An explicit pending reveal wins over an implicit layout anchor. + pv.page = 2; + pv.scroll_to_page_pending = true; + p.col_weight[0] = 1; + p.computeGeom(); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + try std.testing.expectEqual( + @as(f64, @floatFromInt(pv.page_starts[2])), + pv.document_scroll_y, + ); +} + test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { if (!pdf_enabled or platform == .web) return; @@ -302,7 +511,7 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { try std.testing.expectEqual(@as(usize, 0), pv.search_hit); const first = try p.render(frame.allocator()); const revision = first.images[0].?.native.revision; - const first_pan = pv.pan_y; + const first_scroll = pv.document_scroll_y; const first_results = pv.search_results orelse return error.MissingPdfPageSearch; const first_quad = for (first_results.quads) |item| { if (item.hit == 0) break item.quad; @@ -326,7 +535,7 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { _ = frame.reset(.retain_capacity); const third = try p.render(frame.allocator()); try std.testing.expectEqual(revision, third.images[0].?.native.revision); - try std.testing.expect(pv.pan_y != first_pan); + try std.testing.expect(pv.document_scroll_y != first_scroll); const third_results = pv.search_results orelse return error.MissingPdfPageSearch; const third_quad = for (third_results.quads) |item| { if (item.hit == 2) break item.quad; @@ -462,15 +671,18 @@ test "PDF native mouse selection, Look, and highlights share page geometry" { try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind); try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind); const revision_before_reveal = pv.render_revision; - pv.pan_y = std.math.maxInt(u16); + const max_document_scroll = @as(f64, @floatFromInt( + pv.document_height -| p.pdfViewport(pane).?.pixel_h, + )); + pv.document_scroll_y = max_document_scroll; p.revealPdfSearch(pane, pv); - const revealed_pan = pv.pan_y; - try std.testing.expect(revealed_pan != std.math.maxInt(u16)); + const revealed_scroll = pv.document_scroll_y; + try std.testing.expect(revealed_scroll != max_document_scroll); try std.testing.expectEqual(revision_before_reveal, pv.render_revision); - pv.pan_y = std.math.maxInt(u16); + pv.document_scroll_y = max_document_scroll; p.revealPdfSearch(pane, pv); // one shot: subsequent manual pan stays put - try std.testing.expectEqual(std.math.maxInt(u16), pv.pan_y); - pv.pan_y = revealed_pan; + try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y); + pv.document_scroll_y = revealed_scroll; _ = frame.reset(.retain_capacity); const searched = try p.render(frame.allocator()); try std.testing.expect(searched.images[0].?.native.revision != selected_revision); @@ -1285,10 +1497,12 @@ pub const Cell = struct { /// both: pane slots are reused, while a live pane may replace its pixels. pub const ImageCacheKey = if (pdf_enabled) struct { serial: u32, + page: u32, revision: u32, pub fn eql(a: @This(), b: @This()) bool { - return a.serial == b.serial and a.revision == b.revision; + return a.serial == b.serial and a.page == b.page and + a.revision == b.revision; } } else struct { serial: u32, @@ -1309,16 +1523,22 @@ const PdfFitMode = if (pdf_enabled) enum { width, height } else void; /// only their pane identity, so feature-off builds carry a zero-bit payload. pub const NativePlacement = if (pdf_enabled) struct { revision: u32 = 0, + page: u32 = 0, fit: image.NativeFit = .contain, pan_x: u16 = 0, pan_y: u16 = 0, + /// Continuous-document pages supply their already-clipped geometry. + /// Static images and legacy callers leave this null and derive fit/pan. + geometry: ?image.NativeGeometry = null, + /// Subpixel vertical displacement retained from proportional SDL wheel dy. + pixel_offset_y: f32 = 0, } else struct {}; /// A pixel image riding the surface: the shell transmits/places it over the /// given cell rect (tty: Kitty graphics; SDL: alpha-blended GPU texture). /// This is also the backend-neutral transport for rasterized PDF pages. pub const ImagePlace = struct { - pane: u8, // cache slot + pane: u8, /// Pane slots are reused. This identity makes a cached GPU texture or /// kitty image unambiguously belong to the pane which supplied the bytes. serial: u32, @@ -1333,17 +1553,22 @@ pub const ImagePlace = struct { pub fn cacheKey(place: ImagePlace) ImageCacheKey { if (comptime pdf_enabled) - return .{ .serial = place.serial, .revision = place.native.revision }; + return .{ + .serial = place.serial, + .page = place.native.page, + .revision = place.native.revision, + }; return .{ .serial = place.serial }; } }; test "pixel attachment cache key follows both pane lifetime and rendered revision" { if (comptime pdf_enabled) { - const first = ImageCacheKey{ .serial = 41, .revision = 3 }; - try std.testing.expect(first.eql(.{ .serial = 41, .revision = 3 })); - try std.testing.expect(!first.eql(.{ .serial = 41, .revision = 4 })); - try std.testing.expect(!first.eql(.{ .serial = 42, .revision = 3 })); + const first = ImageCacheKey{ .serial = 41, .page = 7, .revision = 3 }; + try std.testing.expect(first.eql(.{ .serial = 41, .page = 7, .revision = 3 })); + try std.testing.expect(!first.eql(.{ .serial = 41, .page = 8, .revision = 3 })); + try std.testing.expect(!first.eql(.{ .serial = 41, .page = 7, .revision = 4 })); + try std.testing.expect(!first.eql(.{ .serial = 42, .page = 7, .revision = 3 })); } else { const first = ImageCacheKey{ .serial = 41 }; try std.testing.expect(first.eql(.{ .serial = 41 })); @@ -1389,7 +1614,10 @@ pub const Surface = struct { cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, - images: [MAX_PANES]?ImagePlace = @splat(null), + /// Pixel attachments are the exact visible set. PDFs can legally contain + /// arbitrarily short pages, so no fixed page-count array can represent a + /// viewport without occasionally dropping an intersecting page. + images: []?ImagePlace = &.{}, nimages: usize = 0, pub fn at(s: *Surface, x: u16, y: u16) *Cell { @@ -1578,6 +1806,9 @@ pub const Event = union(enum) { /// nothing in the core waits for it. file_changed: struct { pane: u8, bytes: []const u8 }, paste: []const u8, + /// Native shells may preserve sub-cell wheel distance in physical pixels. + /// TTY button events still enter through the ordinary mouse path. + pdf_scroll: struct { pane: u8, delta_pixels: f32 }, pinch: f32, touch_scroll: f32, tick, @@ -1782,6 +2013,19 @@ pub const Image = struct { const PdfPoint = if (pdf_enabled) pdf_impl.Point else void; const PdfHighlight = if (pdf_enabled) pdf_impl.Highlight else void; +const PdfRaster = if (pdf_enabled) struct { + valid: bool = false, + page: usize = 0, + rgba: []u8 = &.{}, + iw: usize = 0, + ih: usize = 0, + request: pdf_impl.RenderRequest = .{}, + request_valid: bool = false, + tried: bool = false, + decorated: bool = false, + revision: u32 = 0, +} else void; + /// A PDF pane is compiled out as a zero-bit `void` field when MuPDF is off. /// With it enabled, the pane owns one live document, one lazy current-page /// raster for SDL/Kitty, and one lazy text projection for non-pixel hosts and @@ -1791,6 +2035,31 @@ const PdfView = if (pdf_enabled) struct { document: pdf_impl.Document, page: usize = 0, page_count: usize, + page_sizes: []pdf_impl.PageSize, + /// Pixel-space layout is recomputed only when fit or physical viewport + /// changes. These tiny arrays replace any all-document raster. + page_starts: []u64, + page_heights: []u32, + document_height: u64 = 0, + layout_viewport_w: u32 = 0, + layout_viewport_h: u32 = 0, + layout_fit: PdfFitMode = .width, + layout_valid: bool = false, + /// Top of the viewport in document display pixels. f64 preserves SDL's + /// fractional wheel distance while page starts/heights remain integral. + document_scroll_y: f64 = 0, + scroll_to_page_pending: bool = true, + /// Exactly the visible pages. Entries are removed (and RGBA freed) the + /// frame a page leaves the viewport, so pixel memory is viewport-bounded + /// even for documents with pathological page dimensions. + rasters: std.ArrayList(PdfRaster) = .empty, + /// A physical viewport change rebuilds page heights. Preserve the same + /// page-relative reading position instead of reinterpreting old pixels or + /// snapping to the active page's top. + layout_anchor_pending: bool = false, + layout_anchor_page: usize = 0, + layout_anchor_fraction: f64 = 0, + next_raster_revision: u32 = 0, fit: PdfFitMode = .width, /// Normalized 0..65535 pan positions. NativeGeometry maps the extrema /// exactly onto the source overflow, independent of page or cell size. @@ -2439,6 +2708,7 @@ pub const Pardes = struct { p.effects.deinit(gpa); p.scratch.deinit(); gpa.free(p.surface.cells); + if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); } @@ -2475,7 +2745,14 @@ pub const Pardes = struct { if (comptime pdf_enabled) { if (pane.pdf) |*pv| { p.gpa.free(pv.path); - if (pv.rgba.len > 0) p.gpa.free(pv.rgba); + for (pv.rasters.items) |raster| if (raster.rgba.len > 0) + p.gpa.free(raster.rgba); + pv.rasters.deinit(p.gpa); + p.gpa.free(pv.page_sizes); + p.gpa.free(pv.page_starts); + p.gpa.free(pv.page_heights); + // `rgba` aliases the active raster for legacy geometry state; + // its owner was released above. if (pv.text.len > 0) p.gpa.free(pv.text); if (pv.search_query.len > 0) p.gpa.free(pv.search_query); if (pv.search_results) |*results| results.deinit(p.gpa); @@ -2663,8 +2940,11 @@ pub const Pardes = struct { for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf) |*pv| { - if (!std.meta.eql(before[id], p.pdfViewport(pane))) + if (!std.meta.eql(before[id], p.pdfViewport(pane))) { + capturePdfLayoutAnchor(pv); + pv.layout_valid = false; pv.search_reveal_pending = pv.search_query.len > 0; + } } } } else { @@ -2690,6 +2970,16 @@ pub const Pardes = struct { .file_changed => |fc| p.fileChanged(fc.pane, fc.bytes), .key => |key| p.handleKey(key), .mouse => |m| p.handleMouse(m), + .pdf_scroll => |scroll| { + if (comptime pdf_enabled) { + if (scroll.pane < MAX_PANES) { + if (p.panes[scroll.pane]) |pane| { + if (hasPdf(pane) and p.native_images) + _ = p.scrollPdfDocument(pane, @floatCast(scroll.delta_pixels)); + } + } + } + }, .paste => |bytes| { // a shell-level paste (bracketed/SDL): load the register, paste p.setYank(bytes); @@ -7673,6 +7963,14 @@ pub const Pardes = struct { if (comptime !pdf_enabled) return error.PdfDisabled; var document = try pdf_impl.Document.open(path); errdefer document.deinit(); + const page_sizes = try p.gpa.alloc(pdf_impl.PageSize, document.pages); + errdefer p.gpa.free(page_sizes); + for (page_sizes, 0..) |*size, page_number| + size.* = try document.pageSize(page_number); + const page_starts = try p.gpa.alloc(u64, document.pages); + errdefer p.gpa.free(page_starts); + const page_heights = try p.gpa.alloc(u32, document.pages); + errdefer p.gpa.free(page_heights); const path_copy = try p.gpa.dupe(u8, path); errdefer p.gpa.free(path_copy); const pane = try p.newDocPane(id); @@ -7685,6 +7983,9 @@ pub const Pardes = struct { .document = document, .page = page, .page_count = document.pages, + .page_sizes = page_sizes, + .page_starts = page_starts, + .page_heights = page_heights, }; pane.cur_pinned = true; return pane; @@ -7698,12 +7999,25 @@ pub const Pardes = struct { if (comptime !pdf_enabled) return; _ = p; pv.render_tried = false; + for (pv.rasters.items) |*raster| if (raster.valid and raster.page == pv.page) { + raster.tried = false; + break; + }; + } + + fn releasePdfRaster(p: *Pardes, raster: *PdfRaster) void { + if (comptime !pdf_enabled) return; + if (raster.rgba.len > 0) p.gpa.free(raster.rgba); + raster.* = .{}; } fn dropPdfRaster(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; - if (pv.rgba.len > 0) p.gpa.free(pv.rgba); + for (pv.rasters.items) |*raster| p.releasePdfRaster(raster); + pv.rasters.clearRetainingCapacity(); pv.rgba = &.{}; + pv.iw = 0; + pv.ih = 0; pv.render_tried = false; pv.render_request_valid = false; } @@ -7738,23 +8052,17 @@ pub const Pardes = struct { if (changed) p.invalidatePdfRaster(pv); } - /// Drop everything derived from one PDF page. The document and full search - /// query are semantic document state and survive; page search geometry, - /// selection, pixels, projected text and pans do not. + /// Drop semantic state tied to the active page. Plain cached pixels remain + /// reusable if that page is still adjacent in the continuous viewport. fn invalidatePdfPage(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; - p.dropPdfRaster(pv); p.dropPdfSearchResults(pv); p.dropPdfSelection(pv); clearPdfDrag(pv); if (pv.text.len > 0) p.gpa.free(pv.text); - pv.iw = 0; - pv.ih = 0; pv.text = &.{}; pv.text_tried = false; pv.text_scroll = 0; - pv.pan_x = 0; - pv.pan_y = 0; pv.highlights_dirty = pv.search_query.len > 0; pv.search_reveal_pending = pv.search_query.len > 0; pv.search_hit = 0; @@ -7829,53 +8137,64 @@ pub const Pardes = struct { pv.search_reveal_pending = false; return; }; - const geometry = p.pdfGeometry(pane) orelse return; pv.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; - const full: u32 = @intCast(switch (pv.fit) { - .width => pv.ih, - .height => pv.iw, - }); - const at = switch (pv.fit) { - .width => normalizedPdfPixel(center_y, pv.ih), - .height => normalizedPdfPixel(center_x, pv.iw), - }; - const crop_at = switch (pv.fit) { - .width => geometry.src.y, - .height => geometry.src.x, - }; - const crop = switch (pv.fit) { - .width => geometry.src.h, - .height => geometry.src.w, - }; - if (at >= crop_at and at < crop_at + crop) return; - const overflow = full -| crop; + if (pv.fit == .width) { + const laid_out = p.ensurePdfLayout(pane, pv) orelse return; + const page_y = @as(f64, @floatFromInt(pv.page_starts[pv.page])) + + @as(f64, center_y) * @as(f64, @floatFromInt(pv.page_heights[pv.page])); + const wanted = page_y - @as(f64, @floatFromInt(laid_out.pixel_h)) / 2; + const max_scroll = @as(f64, @floatFromInt(pv.document_height -| laid_out.pixel_h)); + pv.document_scroll_y = std.math.clamp(wanted, 0, max_scroll); + return; + } + const geometry = p.pdfGeometry(pane) orelse return; + const full: u32 = @intCast(pv.iw); + const at = normalizedPdfPixel(center_x, pv.iw); + if (at >= geometry.src.x and at < geometry.src.x + geometry.src.w) return; + const overflow = full -| geometry.src.w; if (overflow == 0) return; - const wanted = @min(overflow, at -| crop / 2); - const pan: u16 = @intCast( + const wanted = @min(overflow, at -| geometry.src.w / 2); + pv.pan_x = @intCast( (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, ); - switch (pv.fit) { - .width => pv.pan_y = pan, - .height => pv.pan_x = pan, - } } - /// Change the visible PDF page and invalidate only page-derived caches. - /// The document/context survives, so navigation never reparses the file. - fn setPdfPage(p: *Pardes, pane: *Pane, page: usize) void { + fn activatePdfPage(p: *Pardes, pane: *Pane, page: usize, reveal: bool) void { if (comptime !pdf_enabled) return; const pv = &(pane.pdf orelse return); const next = @min(page, pv.page_count -| 1); - if (next == pv.page) return; - p.invalidatePdfPage(pv); - pv.page = next; - pane.cur_row = 0; - pane.cur_col = 0; - pane.vsel.active = false; - pane.msel.active = false; - pane.nsel = 0; + if (next != pv.page) { + p.invalidatePdfPage(pv); + pv.page = next; + pane.cur_row = 0; + pane.cur_col = 0; + pane.vsel.active = false; + pane.msel.active = false; + pane.nsel = 0; + } + // Input events can be queued behind a scroll in the same shell batch. + // If the newly active adjacent page is already resident, publish its + // dimensions immediately so the following click/pan cannot combine + // its geometry with the prior page's aliases. + if (pdfRasterForPage(pv, next)) |raster| syncPdfRasterAliases(pv, raster); + if (reveal) { + pv.scroll_to_page_pending = true; + if (pv.layout_valid) { + pv.document_scroll_y = @floatFromInt(pv.page_starts[next]); + pv.scroll_to_page_pending = false; + } + } else { + // Scrolling into another page must not turn a pending search into + // a snap back to its center; manual document motion stays manual. + pv.search_reveal_pending = false; + } + } + + /// Reveal one page in the continuous strip without reparsing the document. + fn setPdfPage(p: *Pardes, pane: *Pane, page: usize) void { + p.activatePdfPage(pane, page, true); } /// Builtin entry point. Public only because builtins.zig is the command @@ -7887,6 +8206,8 @@ pub const Pardes = struct { pv.fit = if (pv.fit == .width) .height else .width; pv.pan_x = 0; pv.pan_y = 0; + pv.layout_valid = false; + pv.scroll_to_page_pending = true; pv.search_reveal_pending = pv.search_query.len > 0; } @@ -7902,6 +8223,7 @@ pub const Pardes = struct { pixel_w: u32, pixel_h: u32, }; + const PdfVisiblePages = struct { first: usize = 0, len: usize = 0 }; const PdfPanAxis = enum { horizontal, vertical }; const PdfPanResult = enum { moved, edge, unavailable }; @@ -7922,6 +8244,117 @@ pub const Pardes = struct { }; } + fn pdfPageHeight(size: pdf_impl.PageSize, viewport: PdfViewport, fit: PdfFitMode) u32 { + if (comptime !pdf_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), + )))); + } + + /// Rebuild only O(page-count) point-size layout metadata. No page pixels + /// are touched, and ordinary scrolling never enters this path. + fn ensurePdfLayout(p: *Pardes, pane: *Pane, pv: *PdfView) ?PdfViewport { + if (comptime !pdf_enabled) return null; + const viewport = p.pdfViewport(pane) orelse return null; + // Pane-local geometry changes (divider drags, splits, closes) do not + // pass through Event.resize. Capture against the still-valid OLD + // layout here, immediately before replacing its page heights. A shell + // resize has already invalidated the layout after capturing, while an + // explicit page reveal owns scroll_to_page_pending; neither may be + // captured a second time or overridden by this implicit anchor. + if (pv.layout_valid and !pv.scroll_to_page_pending and + !pv.layout_anchor_pending and + (pv.layout_viewport_w != viewport.pixel_w or + pv.layout_viewport_h != viewport.pixel_h)) + { + capturePdfLayoutAnchor(pv); + } + if (!pv.layout_valid or pv.layout_viewport_w != viewport.pixel_w or + pv.layout_viewport_h != viewport.pixel_h or pv.layout_fit != pv.fit) + { + var at: u64 = 0; + for (pv.page_sizes, 0..) |size, page| { + pv.page_starts[page] = at; + const height = pdfPageHeight(size, viewport, pv.fit); + pv.page_heights[page] = height; + at = std.math.add(u64, at, height) catch std.math.maxInt(u64); + if (page + 1 < pv.page_count) + at = std.math.add(u64, at, PDF_PAGE_GAP_PX) catch std.math.maxInt(u64); + } + pv.document_height = at; + pv.layout_viewport_w = viewport.pixel_w; + pv.layout_viewport_h = viewport.pixel_h; + pv.layout_fit = pv.fit; + pv.layout_valid = true; + if (pv.scroll_to_page_pending) { + pv.document_scroll_y = @floatFromInt(pv.page_starts[pv.page]); + pv.scroll_to_page_pending = false; + pv.layout_anchor_pending = false; + } else if (pv.layout_anchor_pending) { + const anchor_page = @min(pv.layout_anchor_page, pv.page_count -| 1); + pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[anchor_page])) + + pv.layout_anchor_fraction * @as(f64, @floatFromInt(pv.page_heights[anchor_page])); + pv.layout_anchor_pending = false; + } + } + const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h)); + pv.document_scroll_y = std.math.clamp(pv.document_scroll_y, 0, max_scroll); + return viewport; + } + + fn capturePdfLayoutAnchor(pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (!pv.layout_valid or pv.page_count == 0 or pv.document_height == 0) return; + const page = pdfPageAtOffset(pv, pv.document_scroll_y); + const start: f64 = @floatFromInt(pv.page_starts[page]); + const height: f64 = @floatFromInt(@max(@as(u32, 1), pv.page_heights[page])); + pv.layout_anchor_page = page; + pv.layout_anchor_fraction = std.math.clamp((pv.document_scroll_y - start) / height, 0, 1); + pv.layout_anchor_pending = true; + } + + fn pdfPageAtOffset(pv: *const PdfView, offset: f64) usize { + if (comptime !pdf_enabled) return 0; + const y: u64 = @intFromFloat(std.math.clamp( + @floor(offset), + 0, + @as(f64, @floatFromInt(pv.document_height -| 1)), + )); + var lo: usize = 0; + var hi: usize = pv.page_count; + while (lo + 1 < hi) { + const mid = lo + (hi - lo) / 2; + if (pv.page_starts[mid] <= y) lo = mid else hi = mid; + } + const end = pv.page_starts[lo] + pv.page_heights[lo]; + return if (y >= end and lo + 1 < pv.page_count) lo + 1 else lo; + } + + fn pdfPageVisible(pv: *const PdfView, page: usize, viewport: PdfViewport) bool { + if (comptime !pdf_enabled) return false; + const top = @as(f64, @floatFromInt(pv.page_starts[page])) - pv.document_scroll_y; + const bottom = top + @as(f64, @floatFromInt(pv.page_heights[page])); + return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h)); + } + + fn pdfVisiblePages(pv: *const PdfView, viewport: PdfViewport) PdfVisiblePages { + var out: PdfVisiblePages = .{}; + var page = pdfPageAtOffset(pv, pv.document_scroll_y); + if (page > 0 and pdfPageVisible(pv, page - 1, viewport)) page -= 1; + out.first = page; + while (page < pv.page_count) : (page += 1) { + const top = @as(f64, @floatFromInt(pv.page_starts[page])) - pv.document_scroll_y; + if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break; + if (!pdfPageVisible(pv, page, viewport)) continue; + out.len += 1; + } + return out; + } + fn pdfRenderRequest(p: *const Pardes, pane: *const Pane) ?pdf_impl.RenderRequest { if (comptime !pdf_enabled) return null; const viewport = p.pdfViewport(pane) orelse return null; @@ -7933,22 +8366,123 @@ pub const Pardes = struct { }; } - fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry { + const PdfPlacedGeometry = struct { + geometry: image.NativeGeometry, + pixel_offset_y: f32, + }; + + fn pdfRasterForPage(pv: *PdfView, page: usize) ?*PdfRaster { if (comptime !pdf_enabled) return null; - const pv = pane.pdf orelse return null; - const viewport = p.pdfViewport(pane) orelse return null; - return image.nativeGeometry( - pv.iw, - pv.ih, + for (pv.rasters.items) |*raster| + if (raster.valid and raster.page == page) return raster; + return null; + } + + fn syncPdfRasterAliases(pv: *PdfView, raster: *const PdfRaster) void { + if (comptime !pdf_enabled) return; + pv.rgba = raster.rgba; + pv.iw = raster.iw; + pv.ih = raster.ih; + pv.render_tried = raster.tried; + pv.render_request = raster.request; + pv.render_request_valid = raster.request_valid; + pv.render_revision = raster.revision; + } + + fn pdfPlacedGeometry( + pv: *const PdfView, + raster: *const PdfRaster, + viewport: PdfViewport, + page: usize, + ) ?PdfPlacedGeometry { + if (comptime !pdf_enabled) return null; + const page_h = pv.page_heights[page]; + const base = image.nativeGeometry( + raster.iw, + raster.ih, viewport.pixel_w, - viewport.pixel_h, + page_h, switch (pv.fit) { .width => .width, .height => .height, }, pv.pan_x, - pv.pan_y, - ); + 0, + ) orelse return null; + if (base.dst.h == 0 or base.src.h == 0) return null; + + const scroll_floor = @floor(pv.document_scroll_y); + const fractional: f32 = @floatCast(pv.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( + pv.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 .{ + .geometry = .{ + .src = .{ + .x = base.src.x, + .y = src_y0, + .w = base.src.w, + .h = src_y1 - src_y0, + }, + .dst = .{ + .x = base.dst.x, + .y = @intCast(visible_y), + .w = base.dst.w, + .h = @intCast(visible_bottom - visible_y), + }, + }, + .pixel_offset_y = -fractional, + }; + } + + fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry { + if (comptime !pdf_enabled) return null; + const mutable = @constCast(pane); + const pv = if (mutable.pdf) |*view| view else return null; + const viewport = p.pdfViewport(pane) orelse return null; + const raster = pdfRasterForPage(pv, pv.page) orelse return null; + const placed = pdfPlacedGeometry(pv, raster, viewport, pv.page) orelse return null; + return placed.geometry; + } + + fn pdfPageAtGridPoint(p: *const Pardes, pane: *const Pane, mrow: u16) ?usize { + if (comptime !pdf_enabled) return null; + const pv = pane.pdf orelse return null; + if (!pv.layout_valid) return null; + const r = for (p.panes, 0..) |slot, i| { + if (slot == pane) break p.rects[i]; + } else return null; + if (mrow < r.y + BOX_H or mrow >= r.y + r.h) return null; + const local_y = @as(f64, @floatFromInt( + @as(u32, mrow - (r.y + BOX_H)) * p.cell_pixels.h + p.cell_pixels.h / 2, + )); + const page = pdfPageAtOffset(&pv, pv.document_scroll_y + local_y); + const document_y = pv.document_scroll_y + local_y; + const start: f64 = @floatFromInt(pv.page_starts[page]); + if (document_y < start or + document_y >= start + @as(f64, @floatFromInt(pv.page_heights[page]))) return null; + return page; } fn pdfNativeReady(p: *const Pardes, pane: *const Pane) bool { @@ -8044,7 +8578,13 @@ pub const Pardes = struct { snap_word: bool, ) bool { if (comptime !pdf_enabled) return false; - const pv = &(pane.pdf orelse return false); + const hit_page = p.pdfPageAtGridPoint(pane, mrow) orelse return false; + if (hit_page != pane.pdf.?.page) { + p.activatePdfPage(pane, hit_page, false); + if (pdfRasterForPage(&pane.pdf.?, hit_page)) |raster| + syncPdfRasterAliases(&pane.pdf.?, raster); + } + const pv = &pane.pdf.?; clearPdfDrag(pv); const point = p.pdfPointAt(pane, mcol, mrow, false) orelse { return false; @@ -8177,37 +8717,31 @@ pub const Pardes = struct { )); } - /// Fit-width reads down a tall raster, then crosses pages at its extrema. - /// Going backward deliberately enters the previous page at the bottom. - fn panPdfWidth(p: *Pardes, pane: *Pane, direction: i8, pixels: u32) void { - switch (p.panPdfPixels(pane, .vertical, direction, pixels)) { - .moved => {}, - // Before the first native frame (or after a render failure), do - // not swallow navigation: page-wise is the useful fallback. - .unavailable => p.stepPdfPage(pane, direction), - .edge => { - const pv = pane.pdf orelse return; - if (direction > 0) { - if (pv.page + 1 >= pv.page_count) return; - p.setPdfPage(pane, pv.page + 1); - } else { - if (pv.page == 0) return; - p.setPdfPage(pane, pv.page - 1); - pane.pdf.?.pan_y = std.math.maxInt(u16); - } - }, - } + /// Move through the strip by exact display pixels. Page changes update + /// semantic search/selection state, but never evict unchanged page pixels. + fn scrollPdfDocument(p: *Pardes, pane: *Pane, delta_pixels: f64) bool { + if (comptime !pdf_enabled) return false; + if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return false; + const pv = &(pane.pdf orelse return false); + const viewport = p.ensurePdfLayout(pane, pv) orelse return false; + const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h)); + const next = std.math.clamp(pv.document_scroll_y + delta_pixels, 0, max_scroll); + if (next == pv.document_scroll_y) return false; + pv.document_scroll_y = next; + const active_page = pdfPageAtOffset(pv, next); + if (active_page != pv.page) p.activatePdfPage(pane, active_page, false); + return true; } fn pdfVerticalWheel(p: *Pardes, pane: *Pane, direction: i8) void { if (comptime !pdf_enabled) return; - const pv = pane.pdf orelse return; - if (!p.native_images or pv.fit == .height) { + if (!p.native_images) { p.stepPdfPage(pane, direction); return; } const rows: u32 = @intCast(@max(1, config.wheel_rows)); - p.panPdfWidth(pane, direction, scaledPdfStep(p.cell_pixels.h, rows)); + const pixels = scaledPdfStep(p.cell_pixels.h, rows); + _ = p.scrollPdfDocument(pane, @as(f64, @floatFromInt(pixels)) * direction); } fn pdfHorizontalWheel(p: *Pardes, pane: *Pane, direction: i8) void { @@ -8218,9 +8752,8 @@ pub const Pardes = struct { _ = p.panPdfPixels(pane, .horizontal, direction, scaledPdfStep(p.cell_pixels.w, cols)); } - /// In fit-height, vertical motions retain the old page-at-a-time model. - /// In fit-width they pan by a row, half viewport or full viewport before - /// crossing pages. Search, tags and global chords run before this hook. + /// Vertical keys move through the same document-pixel space as the wheel; + /// PdfFit changes page scale, not the continuity of navigation. fn pdfNavigationKey(p: *Pardes, pane: *Pane, key: Key) bool { if (comptime !pdf_enabled) return false; if (pane.pdf == null or pane.pending != 0) return false; @@ -8236,7 +8769,7 @@ pub const Pardes = struct { const count = @max(1, pane.count); pane.count = 0; const direction: i8 = if (down) 1 else -1; - if (!p.native_images or pane.pdf.?.fit == .height) { + if (!p.native_images) { const pages: i64 = @intCast(count); p.stepPdfPage(pane, if (down) pages else -pages); return true; @@ -8250,7 +8783,8 @@ pub const Pardes = struct { v.pixel_h else @as(u32, p.cell_pixels.h); - p.panPdfWidth(pane, direction, scaledPdfStep(base, count)); + const pixels = scaledPdfStep(base, count); + _ = p.scrollPdfDocument(pane, @as(f64, @floatFromInt(pixels)) * direction); return true; } @@ -9201,7 +9735,6 @@ pub const Pardes = struct { s.cols = p.screen_w; s.rows = p.screen_h; s.cursor = null; - s.images = @splat(null); s.nimages = 0; const th = p.theme(); // gaps between panes read as chrome (scrollbar track), not raw default @@ -9435,10 +9968,33 @@ pub const Pardes = struct { // A native PDF page uses the same backend-neutral pixel attachment as // an image. Without native pixels it deliberately falls through: its // extracted text projection becomes an ordinary readable body. - if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) { - s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); - s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg }); - return; + if (comptime pdf_enabled) { + if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) { + s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); + s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg }); + if (r.h > BOX_H) { + const pv = &pane.pdf.?; + const track_h: usize = r.h - BOX_H; + const viewport = p.pdfViewport(pane).?; + const total = @max(@as(u64, 1), pv.document_height); + const len = @max( + @as(usize, 1), + @as(usize, @intCast(@min( + @as(u64, track_h), + @as(u64, track_h) * viewport.pixel_h / total, + ))), + ); + const offset: u64 = @intFromFloat(@floor(pv.document_scroll_y)); + const pos: usize = @intCast(@min( + @as(u64, track_h -| 1), + @as(u64, track_h) * offset / total, + )); + var sy = pos; + while (sy < track_h and sy < pos + len) : (sy += 1) + s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = th.scroll_thumb } }); + } + return; + } } // image pane: the picture fills the body — petscii glyph art into the @@ -9642,66 +10198,141 @@ pub const Pardes = struct { } } - /// Render the current PDF page once per backend-quality request and attach - /// its stable RGBA until pixels or physical viewport quality change. - /// Fit/pan stay placement-only, while every request retains its backend's - /// hard longest-side allocation ceiling. + fn appendImagePlace(p: *Pardes, place: ImagePlace) bool { + const s = &p.surface; + if (s.nimages == s.images.len) { + const old_len = s.images.len; + const new_len = @max(@as(usize, 4), std.math.mul(usize, @max(old_len, 1), 2) catch return false); + const grown = if (old_len == 0) + p.gpa.alloc(?ImagePlace, new_len) catch return false + else + p.gpa.realloc(s.images, new_len) catch return false; + @memset(grown[old_len..], null); + s.images = grown; + } + s.images[s.nimages] = place; + s.nimages += 1; + return true; + } + + fn pdfVisibleContains(visible: PdfVisiblePages, page: usize) bool { + return page >= visible.first and page - visible.first < visible.len; + } + + fn reconcilePdfRasters( + p: *Pardes, + pv: *PdfView, + request: pdf_impl.RenderRequest, + highlights: []const PdfHighlight, + visible: PdfVisiblePages, + ) void { + if (comptime !pdf_enabled) return; + + // Remove first: owned RGBA never accumulates with document length, + // and backends see stale keys disappear in this same frame. + var index: usize = 0; + while (index < pv.rasters.items.len) { + if (pdfVisibleContains(visible, pv.rasters.items[index].page)) { + index += 1; + continue; + } + p.releasePdfRaster(&pv.rasters.items[index]); + _ = pv.rasters.swapRemove(index); + } + + var page = visible.first; + const end = visible.first + visible.len; + while (page < end) : (page += 1) { + var raster = pdfRasterForPage(pv, page); + if (raster == null) { + pv.rasters.append(p.gpa, .{ .valid = true, .page = page }) catch continue; + raster = &pv.rasters.items[pv.rasters.items.len - 1]; + } + const slot = raster.?; + const decorated = page == pv.page and highlights.len > 0; + if (!slot.tried or !slot.request_valid or !slot.request.eql(request) or + slot.decorated != decorated) + { + slot.tried = true; + slot.request = request; + slot.request_valid = true; + const rendered = render: { + const fresh = if (decorated) + pv.document.renderWithHighlightsAt(p.gpa, page, request, highlights) catch break :render null + else + pv.document.renderAt(p.gpa, page, request) catch break :render null; + break :render fresh; + }; + if (rendered) |fresh| { + if (slot.rgba.len > 0) p.gpa.free(slot.rgba); + slot.rgba = fresh.rgba; + slot.iw = fresh.width; + slot.ih = fresh.height; + slot.decorated = decorated; + pv.next_raster_revision +%= 1; + if (pv.next_raster_revision == 0) pv.next_raster_revision = 1; + slot.revision = pv.next_raster_revision; + } + } + if (page == pv.page and slot.rgba.len > 0) syncPdfRasterAliases(pv, slot); + } + } + + /// Attach every page intersecting the document viewport. Raster entries + /// are released as soon as their page leaves that exact visible set; + /// scrolling a retained page changes geometry only. fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool { if (comptime !pdf_enabled) return false; const pv = &(pane.pdf orelse return false); + if (r.h <= BOX_H) return false; + const viewport = p.ensurePdfLayout(pane, pv) orelse return false; const request = p.pdfRenderRequest(pane) orelse return false; - if (!pv.render_tried or !pv.render_request_valid or - !pv.render_request.eql(request)) - { - pv.render_tried = true; - pv.render_request = request; - pv.render_request_valid = true; - p.resolvePdfSearch(pv); - const rendered = render: { - const highlights = p.pdfHighlights(pv) catch break :render null; - const fresh = if (highlights.len > 0) - pv.document.renderWithHighlightsAt(p.gpa, pv.page, request, highlights) catch break :render null - else - pv.document.renderAt(p.gpa, pv.page, request) catch break :render null; - break :render fresh; - }; - if (rendered) |fresh| { - if (pv.rgba.len > 0) p.gpa.free(pv.rgba); - pv.rgba = fresh.rgba; - pv.iw = fresh.width; - pv.ih = fresh.height; - pv.render_revision +%= 1; - if (pv.render_revision == 0) pv.render_revision = 1; - } - } + p.resolvePdfSearch(pv); + const highlights = p.pdfHighlights(pv) catch &.{}; + var visible = pdfVisiblePages(pv, viewport); + p.reconcilePdfRasters(pv, request, highlights, visible); + p.rearmPdfRevealForViewport(pane, pv); p.revealPdfSearch(pane, pv); - if (pv.rgba.len == 0 or r.h <= BOX_H) return false; + // Reveal can move within the current page; placements below use its + // final document coordinate, but unchanged raster pixels are reused. + visible = pdfVisiblePages(pv, viewport); + p.reconcilePdfRasters(pv, request, highlights, visible); const id: u8 = @intCast(for (p.panes, 0..) |slot, i| { if (slot == pane) break i; } else 0); - p.surface.images[p.surface.nimages] = .{ - .pane = id, - .serial = pane.serial, - .native = .{ - .revision = pv.render_revision, - .fit = switch (pv.fit) { - .width => .width, - .height => .height, + var placed_any = false; + var page = visible.first; + const visible_end = visible.first + visible.len; + while (page < visible_end) : (page += 1) { + const slot = pdfRasterForPage(pv, page) orelse continue; + if (slot.rgba.len == 0) continue; + const placed = pdfPlacedGeometry(pv, slot, viewport, page) orelse continue; + if (!p.appendImagePlace(.{ + .pane = id, + .serial = pane.serial, + .native = .{ + .revision = slot.revision, + .page = @intCast(page), + .fit = switch (pv.fit) { + .width => .width, + .height => .height, + }, + .pan_x = pv.pan_x, + .geometry = placed.geometry, + .pixel_offset_y = placed.pixel_offset_y, }, - .pan_x = pv.pan_x, - .pan_y = pv.pan_y, - }, - .x = tx, - .y = r.y + BOX_H, - .w = tw, - .h = r.h - BOX_H, - .rgba = pv.rgba, - .iw = pv.iw, - .ih = pv.ih, - }; - p.surface.nimages += 1; - return true; + .x = tx, + .y = r.y + BOX_H, + .w = tw, + .h = r.h - BOX_H, + .rgba = slot.rgba, + .iw = slot.iw, + .ih = slot.ih, + })) break; + placed_any = true; + } + return placed_any; } /// Image pane body: decode once (path via look, or dump-loaded raw bytes), @@ -9724,7 +10355,7 @@ pub const Pardes = struct { const body_rows = r.h - BOX_H; const use_petscii = iv.petscii or !p.native_images; if (!use_petscii) { - s.images[s.nimages] = .{ + if (!p.appendImagePlace(.{ .pane = @intCast(for (p.panes, 0..) |slot, i| { if (slot == pane) break i; } else 0), @@ -9736,8 +10367,7 @@ pub const Pardes = struct { .rgba = iv.rgba, .iw = iv.iw, .ih = iv.ih, - }; - s.nimages += 1; + })) return; return; } // (re)build the cached glyph grid if the size or toggles changed |
