summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-14 22:38:50 -0300
committerGabriel Schneider <[email protected]>2026-08-15 11:58:29 -0300
commitbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (patch)
tree1abf5cb65057048ebfbce7fc427289a437a693f4 /src/pardes.zig
parentf67fec978a9296c651ec06bd2f43686d34ff86ee (diff)
downloadpardes-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.zig373
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;
}