summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 21:28:49 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commitec19caddb60e748115adb562b46d007598dcdb95 (patch)
treeaaf282dec5beaf445fc7a5fb194aa438c3ae4986 /src/pardes.zig
parent5d5f2a25011c751452fb4ff42e28ee84d4bb4fdf (diff)
downloadpardes-ec19caddb60e748115adb562b46d007598dcdb95.tar.gz
pardes-ec19caddb60e748115adb562b46d007598dcdb95.zip
render PDFs as a continuous vertical page strip
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig1002
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