diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-14 22:38:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-15 11:58:29 -0300 |
| commit | be2a9957708cbf0c478ca861c4a1f0f227bbfe10 (patch) | |
| tree | 1abf5cb65057048ebfbce7fc427289a437a693f4 /src/pardes.zig | |
| parent | f67fec978a9296c651ec06bd2f43686d34ff86ee (diff) | |
| download | pardes-be2a9957708cbf0c478ca861c4a1f0f227bbfe10.tar.gz pardes-be2a9957708cbf0c478ca861c4a1f0f227bbfe10.zip | |
pdf: continuous scroll bench harness and per-frame render path
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 373 |
1 files changed, 329 insertions, 44 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index af12cb96..3c236f34 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -41,6 +41,11 @@ pub const allocators = @import("allocators.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); +/// Tracy's frame boundary, re-exported so a host that is not a shell — the +/// fling benchmark — can delimit the same frames the tty loop delimits without +/// reaching around the core for src/tracy.zig and its build options. A no-op +/// unless -Dtracy names a Tracy checkout. +pub const frameMark = tracy.frameMark; pub const Platform = enum { tty, gui, web, macos }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); @@ -257,7 +262,14 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { // 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. + // + // This lands by ASSIGNMENT, which is a jump and not a fling — and the + // travel counter has to say so: the keys above scrolled two screenfuls + // without any frame in between to spend that distance, which no shell does + // (every wheel batch is followed by a draw). Left unspent it would make the + // frame below the first frame of a fling and hand it bands. const viewport = p.pdfViewport(pane).?; + pane.pdf.?.scroll_travel = 0; pane.pdf.?.document_scroll_y = @floatFromInt( pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2, ); @@ -430,6 +442,107 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint); } +test "a fling's banded pages show the reader exactly what whole pages would" { + if (!pdf_enabled or platform == .web) return; + + // The contract fast scrolling is allowed to change: HOW pixels are carried + // (a strip of a page instead of the page) but never WHICH pixels arrive. So + // the same frame is drawn twice — once at fling speed, once at reading + // speed — and every pixel inside every source rectangle must match, along + // with where on screen it goes. + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 }); + defer p.deinit(); + p.native_images = true; + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + + const viewport = p.pdfViewport(pane) orelse return error.MissingPdfViewport; + // Land mid-page-boundary so the frame carries TWO pages, each showing a + // fraction of itself — the shape a fling actually produces. + const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3)); + const Shot = struct { + page: u32, + dst: image.PixelRect, + pixels: []u8, + }; + var shots: [8]Shot = undefined; + var shots_len: usize = 0; + defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels); + + // A fling: one frame's worth of wheel travel carrying the viewport more + // than a screenful, delivered through the real scroll path so the distance + // is counted the way a wheel batch counts it. + pv.document_scroll_y = 0; + pv.scroll_travel = 0; + try std.testing.expect(p.scrollPdfDocument(pane, landing)); + _ = frame.reset(.retain_capacity); + const flung = try p.render(frame.allocator()); + try std.testing.expect(flung.nimages >= 2); + var banded = false; + for (flung.images[0..flung.nimages]) |maybe| { + const place = maybe orelse continue; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse + return error.MissingPdfRaster; + if (raster.band_h < raster.ih) banded = true; + try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len); + shots[shots_len] = .{ + .page = place.native.page, + .dst = geometry.dst, + .pixels = try copySourceRect(gpa, place, geometry.src), + }; + shots_len += 1; + } + // ...and it really did band, or the comparison below is two identical + // whole-page renders agreeing with each other. + try std.testing.expect(banded); + // ...at reading speed: no travel at all since the frame above, so every + // page is rasterized whole again, and that is the picture the banded frame + // has to have matched. + _ = frame.reset(.retain_capacity); + const rested = try p.render(frame.allocator()); + try std.testing.expectEqual(shots_len, rested.nimages); + for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| { + const place = maybe orelse return error.MissingPdfPlacement; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse + return error.MissingPdfRaster; + try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest + try std.testing.expectEqual(shot.page, place.native.page); + try std.testing.expectEqual(shot.dst.x, geometry.dst.x); + try std.testing.expectEqual(shot.dst.y, geometry.dst.y); + try std.testing.expectEqual(shot.dst.w, geometry.dst.w); + try std.testing.expectEqual(shot.dst.h, geometry.dst.h); + const whole = try copySourceRect(gpa, place, geometry.src); + defer gpa.free(whole); + try std.testing.expectEqualSlices(u8, shot.pixels, whole); + } +} + +/// The pixels a backend samples out of one placement: the source rectangle, +/// row by row, at the texture's own stride. Test-only, and the one operation +/// that makes "same picture" mean something when the textures differ in shape. +fn copySourceRect( + gpa: std.mem.Allocator, + place: ImagePlace, + src: image.PixelRect, +) ![]u8 { + const stride = place.iw * 4; + const row_len = @as(usize, src.w) * 4; + const out = try gpa.alloc(u8, row_len * src.h); + errdefer gpa.free(out); + var row: usize = 0; + while (row < src.h) : (row += 1) { + const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4; + @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]); + } + return out; +} + test "PDF normal adapter consumes unsupported actions and navigates page fallback" { if (!pdf_enabled or platform == .web) return; @@ -3708,8 +3821,18 @@ const PdfRaster = if (pdf_enabled) struct { valid: bool = false, page: usize = 0, rgba: []u8 = &.{}, + /// The FULL page raster's dimensions, whatever part of it `rgba` holds. + /// Everything that maps between the document and the screen — placement, + /// the mouse, panning, a search reveal — is arithmetic about the whole + /// page, and none of it wants to know that a fling only rasterized a + /// strip. iw: usize = 0, ih: usize = 0, + /// ...and the strip that is actually here: rows [band_y, band_y + band_h) + /// of that full raster, so `rgba.len == iw * band_h * 4`. A page at rest + /// is the whole page as one band. See `pdfWantedBand`. + band_y: usize = 0, + band_h: usize = 0, request: pdf_impl.RenderRequest = .{}, request_valid: bool = false, tried: bool = false, @@ -3775,6 +3898,15 @@ const PdfView = if (pdf_enabled) struct { layout_anchor_page: usize = 0, layout_anchor_fraction: f64 = 0, next_raster_revision: u32 = 0, + /// Display pixels this view has been SCROLLED by since the last frame it + /// drew — the fling signal, and the reason the core needs no clock: a wheel + /// batch that moves a screenful or more inside one frame lands on pages + /// nobody will see again, and only their visible rows are worth + /// rasterizing (see `pdfWantedBand`). Deliberately counts SCROLLING only: + /// a page jump, a search reveal or a resize moves the viewport just as far + /// but leaves the reader looking at one place, where whole pages are what + /// makes the frames after it free. + scroll_travel: f64 = 0, fit: PdfFitMode = .width, tint: PdfTintMode = .filtered, /// Normalized 0..65535 pan positions. NativeGeometry maps the extrema @@ -12005,6 +12137,8 @@ pub const Pardes = struct { /// 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 tz = tracy.zone(@src(), "pdf.ensure_layout"); + defer tz.end(); 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 @@ -12150,17 +12284,34 @@ pub const Pardes = struct { pv.render_revision = raster.revision; } - fn pdfPlacedGeometry( + /// Which rows of a page's FULL raster the viewport shows, and where they + /// land on screen. Split out of `pdfPlacedGeometry` because the reconcile + /// needs the same answer BEFORE a raster exists — it is what decides how + /// much of the page is worth rasterizing at all (see `pdfWantedBand`), and + /// two copies of this arithmetic would be two chances to place a band one + /// row away from the rows it rendered. + const PdfVisibleRows = struct { + base: image.NativeGeometry, + /// rows [y0, y1) of the full page raster + y0: u32, + y1: u32, + dst_y: u32, + dst_h: u32, + pixel_offset_y: f32, + }; + + fn pdfVisibleRows( pv: *const PdfView, - raster: *const PdfRaster, viewport: PdfViewport, page: usize, - ) ?PdfPlacedGeometry { + iw: usize, + ih: usize, + ) ?PdfVisibleRows { if (comptime !pdf_enabled) return null; const page_h = pv.page_heights[page]; const base = image.nativeGeometry( - raster.iw, - raster.ih, + iw, + ih, viewport.pixel_w, page_h, switch (pv.fit) { @@ -12199,21 +12350,48 @@ pub const Pardes = struct { ))); if (src_y1 <= src_y0) return null; return .{ + .base = base, + .y0 = src_y0, + .y1 = src_y1, + .dst_y = @intCast(visible_y), + .dst_h = @intCast(visible_bottom - visible_y), + .pixel_offset_y = -fractional, + }; + } + + /// The same rows, expressed against the raster that actually exists: a + /// band-rendered page holds only rows [band_y, band_y+band_h), so the + /// source rectangle a backend samples is shifted by the band's origin. + /// A raster that does not cover the visible rows places NOTHING rather + /// than sampling rows it never rendered — the reconcile is what keeps that + /// from happening, and this is the assertion that it did. + fn pdfPlacedGeometry( + pv: *const PdfView, + raster: *const PdfRaster, + viewport: PdfViewport, + page: usize, + ) ?PdfPlacedGeometry { + if (comptime !pdf_enabled) return null; + const rows = pdfVisibleRows(pv, viewport, page, raster.iw, raster.ih) orelse return null; + const band_y: u32 = @intCast(raster.band_y); + const band_end: u32 = @intCast(raster.band_y + raster.band_h); + if (rows.y0 < band_y or rows.y1 > band_end) return null; + return .{ .geometry = .{ .src = .{ - .x = base.src.x, - .y = src_y0, - .w = base.src.w, - .h = src_y1 - src_y0, + .x = rows.base.src.x, + .y = rows.y0 - band_y, + .w = rows.base.src.w, + .h = rows.y1 - rows.y0, }, .dst = .{ - .x = base.dst.x, - .y = @intCast(visible_y), - .w = base.dst.w, - .h = @intCast(visible_bottom - visible_y), + .x = rows.base.dst.x, + .y = rows.dst_y, + .w = rows.base.dst.w, + .h = rows.dst_h, }, }, - .pixel_offset_y = -fractional, + .pixel_offset_y = rows.pixel_offset_y, }; } @@ -12483,12 +12661,19 @@ pub const Pardes = struct { /// 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; + const tz = tracy.zone(@src(), "pdf.scroll_notch"); + defer tz.end(); 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; + // Travel this frame, for the fling test in drawPdf. Accumulated rather + // than compared against the last position because a wheel batch arrives + // as a hundred separate notches and it is their SUM inside one frame + // that says how fast the reader is going. + pv.scroll_travel += @abs(next - pv.document_scroll_y); pv.document_scroll_y = next; const active_page = pdfPageAtOffset(pv, next); if (active_page != pv.page) p.activatePdfPage(pane, active_page, false); @@ -14496,14 +14681,74 @@ pub const Pardes = struct { return page >= visible.first and page - visible.first < visible.len; } + /// How far a frame must travel before its pages are worth only their + /// visible rows. One screenful: at that speed a page is on screen for this + /// frame and gone by the next, so the rows outside the viewport are pixels + /// nobody will ever see. Below it, nothing changes — a reader nudging + /// through a document keeps getting whole pages, which is what makes the + /// following frames free. + fn pdfFlinging(pv: *const PdfView, viewport: PdfViewport) bool { + if (comptime !pdf_enabled) return false; + return pv.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h)); + } + + /// Band heights are rounded out to this many rows, so that consecutive + /// pages of a fling ask for buffers of the SAME length and the retired- + /// buffer relay can hand one page's bytes to the next instead of going back + /// to the allocator every frame. Rounding out also makes the covered rows + /// outlast a few frames of ordinary scrolling. + /// + /// 64 by measurement, not by taste: on the 5363-page Intel manual, grain 64 + /// runs a fling at a median 2.38-2.43 ms per frame against 2.49-2.52 at 128 + /// and 2.93-3.03 at 256 (rounding out pixels nobody sees), while 32 matches + /// 64's median with a visibly worse tail — the buffer lengths stop repeating + /// and the relay starts missing. 48 and 96 are indistinguishable from 64. + const pdf_band_grain: usize = 64; + + /// The rows of `page` worth rasterizing this frame: the whole page at + /// reading speed, and the visible strip (grain-rounded) during a fling. + fn pdfWantedBand( + pv: *const PdfView, + viewport: PdfViewport, + page: usize, + shape: pdf_impl.Raster, + flinging: bool, + ) pdf_impl.Raster.Band { + if (!flinging) return shape.wholePage(); + const rows = pdfVisibleRows(pv, viewport, page, shape.width, shape.height) orelse + return shape.wholePage(); + const grain = @max(@as(usize, 1), pdf_band_grain); + const first = (@as(usize, rows.y0) / grain) * grain; + const last = std.math.divCeil(usize, @as(usize, rows.y1), grain) catch + return shape.wholePage(); + return shape.band(first, last * grain - first); + } + + /// The raster shape a retained slot was rendered against. Identical to what + /// `measureRenderAt` would answer for the same request — which is exactly + /// why the reconcile may use it instead of asking, as long as the request + /// has not changed (a changed request is `stale` and re-measures). + fn pdfSlotShape(slot: *const PdfRaster) pdf_impl.Raster { + const stride = slot.iw * 4; + return .{ + .width = slot.iw, + .height = slot.ih, + .stride = stride, + .len = stride * slot.ih, + }; + } + fn reconcilePdfRasters( p: *Pardes, pv: *PdfView, request: pdf_impl.RenderRequest, highlights: []const PdfHighlight, visible: PdfVisiblePages, + viewport: PdfViewport, ) void { if (comptime !pdf_enabled) return; + const tz = tracy.zone(@src(), "pdf.reconcile"); + defer tz.end(); const tint_key = p.pdfTintKey(pv); // Remove first, and remove EVERYTHING outside the visible set: owned @@ -14526,6 +14771,7 @@ pub const Pardes = struct { if (index != pv.rasters_len) pv.rasters[index] = pv.rasters[pv.rasters_len]; } + const flinging = pdfFlinging(pv, viewport); var page = visible.first; const end = visible.first + visible.len; while (page < end) : (page += 1) { @@ -14538,45 +14784,75 @@ pub const Pardes = struct { } 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 or slot.tint_key == null or - !slot.tint_key.?.eql(tint_key)) - { + // Everything that makes the pixels wrong rather than merely + // insufficient. Checked first and cheaply: a request or tint change + // means the raster's very dimensions may differ, so nothing below + // may reason about the band it holds. + const stale = !slot.tried or !slot.request_valid or + !slot.request.eql(request) or slot.decorated != decorated or + slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or + slot.rgba.len == 0 or slot.band_h == 0; + // ...and then coverage, against the raster ALREADY here — its + // full-page dimensions are this request's, or `stale` would be + // true. Asking MuPDF to measure again per frame per page is what + // this avoids: at reading speed a covered page must cost nothing. + const uncovered = !stale and uncovered: { + const want = pdfWantedBand(pv, viewport, page, pdfSlotShape(slot), flinging); + break :uncovered slot.band_y > want.y or + slot.band_y + slot.band_h < want.y + want.height; + }; + if (stale or uncovered) { slot.tried = true; slot.request = request; slot.request_valid = true; - // Measure first so a retired buffer of exactly this size can - // be reclaimed. Render into that separate buffer and swap - // only on complete success; the old pixels stay presentable - // until then and then become the next page's buffer. - if (pv.document.measureRenderAt(page, request) catch null) |shape| { - if (p.claimPdfRgba(pv, shape.len)) |fresh| { - const filled = filled: { + // Measure first so the band is a range of rows of THIS + // request's raster and a retired buffer can be reclaimed + // against a length that is already known. + const shape_or_null = shape: { + const tz_measure = tracy.zone(@src(), "pdf.measure"); + defer tz_measure.end(); + break :shape pv.document.measureRenderAt(page, request) catch null; + }; + const shape = shape_or_null orelse continue; + const want = pdfWantedBand(pv, viewport, page, shape, flinging); + // Render into a separate buffer and swap only on complete + // success; the old pixels stay presentable until then and then + // become the next page's buffer. + if (p.claimPdfRgba(pv, want.len)) |fresh| { + const filled = filled: { + { + const tz_render = tracy.zone(@src(), "pdf.render_into"); + defer tz_render.end(); pv.document.renderIntoAt( page, request, shape, + want, if (decorated) highlights else &.{}, fresh, ) catch break :filled false; - pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch - break :filled false; - break :filled true; - }; - if (!filled) { - p.retirePdfRgba(pv, fresh); - continue; } - p.retirePdfRgba(pv, slot.rgba); - slot.rgba = fresh; - slot.iw = shape.width; - slot.ih = shape.height; - slot.decorated = decorated; - slot.tint_key = tint_key; - pv.next_raster_revision +%= 1; - if (pv.next_raster_revision == 0) pv.next_raster_revision = 1; - slot.revision = pv.next_raster_revision; + const tz_tint = tracy.zone(@src(), "pdf.tint"); + defer tz_tint.end(); + pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch + break :filled false; + break :filled true; + }; + if (!filled) { + p.retirePdfRgba(pv, fresh); + continue; } + p.retirePdfRgba(pv, slot.rgba); + slot.rgba = fresh; + slot.iw = shape.width; + slot.ih = shape.height; + slot.band_y = want.y; + slot.band_h = want.height; + slot.decorated = decorated; + slot.tint_key = tint_key; + 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); @@ -14589,6 +14865,8 @@ pub const Pardes = struct { /// 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 tz = tracy.zone(@src(), "pdf.draw"); + defer tz.end(); const pv = &(pane.pdf orelse return false); if (r.h <= BOX_H) return false; const viewport = p.ensurePdfLayout(pane, pv) orelse return false; @@ -14596,14 +14874,14 @@ pub const Pardes = struct { p.resolvePdfSearch(pv); const highlights = p.pdfHighlights(pv) catch &.{}; var visible = pdfVisiblePages(pv, viewport); - p.reconcilePdfRasters(pv, request, highlights, visible); + p.reconcilePdfRasters(pv, request, highlights, visible, viewport); p.rearmPdfRevealForViewport(pane, pv); p.revealPdfSearch(pane, pv); // 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); + p.reconcilePdfRasters(pv, request, highlights, visible, viewport); const id: u8 = @intCast(for (p.panes, 0..) |slot, i| { if (slot == pane) break i; } else 0); @@ -14634,10 +14912,17 @@ pub const Pardes = struct { .h = r.h - BOX_H, .rgba = slot.rgba, .iw = slot.iw, - .ih = slot.ih, + // the TEXTURE is the band that is here, not the page it is cut + // from: rgba.len == iw * band_h * 4, and the placement's source + // rectangle is already band-local + .ih = slot.band_h, })) break; placed_any = true; } + // This frame has now shown whatever its travel earned; the next frame's + // speed is its own. Reset only when something was actually drawn: a pane + // too short to place a page has not spent the reader's scrolling. + if (placed_any) pv.scroll_travel = 0; return placed_any; } |
