summaryrefslogtreecommitdiff
path: root/src
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
parent5d5f2a25011c751452fb4ff42e28ee84d4bb4fdf (diff)
downloadpardes-ec19caddb60e748115adb562b46d007598dcdb95.tar.gz
pardes-ec19caddb60e748115adb562b46d007598dcdb95.zip
render PDFs as a continuous vertical page strip
Diffstat (limited to 'src')
-rw-r--r--src/gui/gui.zig174
-rw-r--r--src/pardes.zig1002
-rw-r--r--src/pdf.zig16
-rw-r--r--src/pdf_bridge.c33
-rw-r--r--src/pdf_bridge.h12
-rw-r--r--src/tty/tty.zig196
6 files changed, 1162 insertions, 271 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index efba7bda..5744b3f2 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -191,10 +191,7 @@ const ImageInstance = extern struct {
v1: f32,
};
-const NativeImage = struct {
- key: pardes.ImageCacheKey,
- texture: *c.SDL_GPUTexture,
-};
+const initial_image_capacity: u32 = pardes.MAX_PANES;
fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool {
if (comptime pardes.pdf_enabled) {
@@ -855,7 +852,8 @@ const Gui = struct {
overlay_vertices: []OverlayVertex,
image_vbuf: *c.SDL_GPUBuffer,
image_vxfer: *c.SDL_GPUTransferBuffer,
- native_images: [pardes.MAX_PANES]?NativeImage = @splat(null),
+ image_capacity: u32 = 0,
+ native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty,
font: *c.UIFont,
/// the file behind `font`, when it is one the Font builtin loaded. Empty
@@ -1095,7 +1093,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 };
const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate;
- const image_buffer_size: u32 = @intCast(pardes.MAX_PANES * @sizeOf(ImageInstance));
+ const image_buffer_size: u32 = initial_image_capacity * @sizeOf(ImageInstance);
var ivb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = image_buffer_size, .props = 0 };
const image_vbuf = c.SDL_CreateGPUBuffer(device, &ivb_info) orelse return error.GpuCreate;
var ivx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = image_buffer_size, .props = 0 };
@@ -1123,6 +1121,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.overlay_vertices = overlay_vertices,
.image_vbuf = image_vbuf,
.image_vxfer = image_vxfer,
+ .image_capacity = initial_image_capacity,
.font = font,
.px = px,
.scale = scale,
@@ -1135,9 +1134,12 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.capture_dir = capture_dir orelse "",
};
defer g.glyphs.deinit();
- defer clearNativeImages(&g);
- defer c.SDL_ReleaseGPUTransferBuffer(device, image_vxfer);
- defer c.SDL_ReleaseGPUBuffer(device, image_vbuf);
+ defer {
+ clearNativeImages(&g);
+ g.native_images.deinit(gpa);
+ }
+ defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer);
+ defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf);
defer c.SDL_ReleaseGPUGraphicsPipeline(device, image_pipeline);
defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran
defer gpa.free(g.scroll_edge); // grown on demand by stepScroll
@@ -2102,10 +2104,25 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
resetScroll(g);
};
if (hit) |id| {
- g.scroll_pane = id;
- g.scroll_col = mc.col;
- g.scroll_row = mc.row;
- g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ const pdf_target = if (comptime pardes.pdf_enabled)
+ core.native_images and core.panes[id].?.pdfPage() != null
+ else
+ false;
+ if (pdf_target) {
+ // PDF placements live in physical document space, so
+ // preserve SDL's raw magnitude directly instead of
+ // quantizing through synthetic wheel buttons/rows.
+ resetScroll(g);
+ core.update(.{ .pdf_scroll = .{
+ .pane = @intCast(id),
+ .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)),
+ } });
+ } else {
+ g.scroll_pane = id;
+ g.scroll_col = mc.col;
+ g.scroll_row = mc.row;
+ g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ }
}
}
if (w.x != 0) core.update(.{ .mouse = .{
@@ -2798,15 +2815,56 @@ fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect
// render: Surface → instanced quads → SDL GPU
// =====================================================================
-fn releaseNativeImage(g: *Gui, pane: usize) void {
- if (g.native_images[pane]) |cached| {
- c.SDL_ReleaseGPUTexture(g.device, cached.texture);
- g.native_images[pane] = null;
- }
+fn releaseNativeImage(g: *Gui, key: pardes.ImageCacheKey) void {
+ if (g.native_images.fetchRemove(key)) |removed|
+ c.SDL_ReleaseGPUTexture(g.device, removed.value);
}
fn clearNativeImages(g: *Gui) void {
- for (0..g.native_images.len) |pane| releaseNativeImage(g, pane);
+ var iterator = g.native_images.valueIterator();
+ while (iterator.next()) |texture| c.SDL_ReleaseGPUTexture(g.device, texture.*);
+ g.native_images.clearRetainingCapacity();
+}
+
+fn surfaceHasNativeKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool {
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (validImageBytes(place) != null and place.cacheKey().eql(key)) return true;
+ }
+ return false;
+}
+
+fn ensureImageBuffers(g: *Gui, needed: u32) bool {
+ if (needed <= g.image_capacity) return true;
+ var capacity = @max(@as(u32, 1), g.image_capacity);
+ while (capacity < needed)
+ capacity = std.math.mul(u32, capacity, 2) catch return false;
+ const buffer_size = std.math.mul(
+ u32,
+ capacity,
+ @as(u32, @intCast(@sizeOf(ImageInstance))),
+ ) catch return false;
+ var vb_info = c.SDL_GPUBufferCreateInfo{
+ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX,
+ .size = buffer_size,
+ .props = 0,
+ };
+ const vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return false;
+ var vx_info = c.SDL_GPUTransferBufferCreateInfo{
+ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD,
+ .size = buffer_size,
+ .props = 0,
+ };
+ const vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &vx_info) orelse {
+ c.SDL_ReleaseGPUBuffer(g.device, vbuf);
+ return false;
+ };
+ c.SDL_ReleaseGPUTransferBuffer(g.device, g.image_vxfer);
+ c.SDL_ReleaseGPUBuffer(g.device, g.image_vbuf);
+ g.image_vbuf = vbuf;
+ g.image_vxfer = vxfer;
+ g.image_capacity = capacity;
+ return true;
}
fn validImageBytes(place: pardes.ImagePlace) ?u32 {
@@ -2828,6 +2886,7 @@ fn nativeBounds(g: *const Gui, place: pardes.ImagePlace) ?NativeBounds {
fn nativePlaceGeometry(g: *const Gui, place: pardes.ImagePlace) ?pardes.image.NativeGeometry {
if (comptime pardes.pdf_enabled) {
+ if (place.native.geometry) |geometry| return geometry;
const bounds = nativeBounds(g, place) orelse return null;
return pardes.image.nativeGeometry(
place.iw,
@@ -2849,13 +2908,15 @@ fn nativePlaceDrawable(g: *const Gui, place: pardes.ImagePlace) bool {
return fit.w != 0 and fit.h != 0;
}
-fn uploadNativeTexture(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, place: pardes.ImagePlace) !void {
+fn uploadNativeTexture(
+ g: *Gui,
+ gpa: std.mem.Allocator,
+ cmd: *c.SDL_GPUCommandBuffer,
+ place: pardes.ImagePlace,
+) !void {
const byte_len = validImageBytes(place) orelse return error.BadImage;
- const pane: usize = place.pane;
- if (g.native_images[pane]) |cached| {
- if (cached.key.eql(place.cacheKey())) return;
- releaseNativeImage(g, pane);
- }
+ const key = place.cacheKey();
+ if (g.native_images.contains(key)) return;
var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
tex_info.type = c.SDL_GPU_TEXTURETYPE_2D;
@@ -2875,7 +2936,7 @@ fn uploadNativeTexture(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, place: pardes.Imag
.props = 0,
};
const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate;
- errdefer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
+ defer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap);
@memcpy(mapped[0..byte_len], place.rgba);
c.SDL_UnmapGPUTransferBuffer(g.device, transfer);
@@ -2903,36 +2964,40 @@ fn uploadNativeTexture(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, place: pardes.Imag
// SDL defers destruction until the submitted copy is done; the staging
// allocation is never needed again, so do not retain a second full image
// beside the texture for the life of the pane.
- c.SDL_ReleaseGPUTransferBuffer(g.device, transfer);
- g.native_images[pane] = .{ .key = place.cacheKey(), .texture = texture };
+ try g.native_images.put(gpa, key, texture);
}
/// Upload new pixel generations and the small per-frame placement buffer.
/// Returns the number of image instances drawNativeImagesGpu will consume.
fn prepareNativeImages(
g: *Gui,
+ gpa: std.mem.Allocator,
cmd: *c.SDL_GPUCommandBuffer,
surface: *pardes.Surface,
sw: u32,
sh: u32,
) u32 {
- var seen: [pardes.MAX_PANES]bool = @splat(false);
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
if (validImageBytes(place) == null) continue;
- seen[place.pane] = true;
- uploadNativeTexture(g, cmd, place) catch continue;
+ uploadNativeTexture(g, gpa, cmd, place) catch continue;
}
- for (seen, 0..) |is_seen, pane| if (!is_seen) releaseNativeImage(g, pane);
+
+ var stale: std.ArrayList(pardes.ImageCacheKey) = .empty;
+ defer stale.deinit(gpa);
+ var iterator = g.native_images.iterator();
+ while (iterator.next()) |entry|
+ if (!surfaceHasNativeKey(surface, entry.key_ptr.*))
+ stale.append(gpa, entry.key_ptr.*) catch {};
+ for (stale.items) |key| releaseNativeImage(g, key);
var count: u32 = 0;
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
- if (g.native_images[place.pane]) |cached| {
- if (cached.key.eql(place.cacheKey()) and nativePlaceDrawable(g, place)) count += 1;
- }
+ if (g.native_images.contains(place.cacheKey()) and nativePlaceDrawable(g, place)) count += 1;
}
if (count == 0) return 0;
+ if (!ensureImageBuffers(g, count)) return 0;
const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return 0);
const instances: [*]ImageInstance = @ptrCast(@alignCast(ptr));
@@ -2941,22 +3006,24 @@ fn prepareNativeImages(
var idx: u32 = 0;
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
- const cached = g.native_images[place.pane] orelse continue;
- if (!cached.key.eql(place.cacheKey())) continue;
+ if (!g.native_images.contains(place.cacheKey())) continue;
const body_x = @as(u32, place.x) * g.cell_w;
const body_y = @as(u32, place.y) * g.cell_h;
if (comptime pardes.pdf_enabled) {
const geometry = nativePlaceGeometry(g, place) orelse continue;
- const px0 = body_x + geometry.dst.x;
- const py0 = body_y + geometry.dst.y;
+ const px0 = @as(f32, @floatFromInt(body_x + geometry.dst.x));
+ const py0 = @as(f32, @floatFromInt(body_y + geometry.dst.y)) +
+ place.native.pixel_offset_y;
+ const px1 = px0 + @as(f32, @floatFromInt(geometry.dst.w));
+ const py1 = py0 + @as(f32, @floatFromInt(geometry.dst.h));
const image_w: f32 = @floatFromInt(place.iw);
const image_h: f32 = @floatFromInt(place.ih);
instances[idx] = .{
- .x0 = (@as(f32, @floatFromInt(px0)) / win_w) * 2.0 - 1.0,
- .y0 = 1.0 - (@as(f32, @floatFromInt(py0)) / win_h) * 2.0,
- .x1 = (@as(f32, @floatFromInt(px0 + geometry.dst.w)) / win_w) * 2.0 - 1.0,
- .y1 = 1.0 - (@as(f32, @floatFromInt(py0 + geometry.dst.h)) / win_h) * 2.0,
+ .x0 = (px0 / win_w) * 2.0 - 1.0,
+ .y0 = 1.0 - (py0 / win_h) * 2.0,
+ .x1 = (px1 / win_w) * 2.0 - 1.0,
+ .y1 = 1.0 - (py1 / win_h) * 2.0,
.u0 = @as(f32, @floatFromInt(geometry.src.x)) / image_w,
.v0 = @as(f32, @floatFromInt(geometry.src.y)) / image_h,
.u1 = @as(f32, @floatFromInt(geometry.src.x + geometry.src.w)) / image_w,
@@ -2996,16 +3063,29 @@ fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surf
var idx: u32 = 0;
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
- const cached = g.native_images[place.pane] orelse continue;
- if (!cached.key.eql(place.cacheKey())) continue;
+ const texture = g.native_images.get(place.cacheKey()) orelse continue;
if (!nativePlaceDrawable(g, place)) continue;
- const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = cached.texture, .sampler = g.atlas_sampler };
+ const clip = c.SDL_Rect{
+ .x = @intCast(@as(u32, place.x) * g.cell_w),
+ .y = @intCast(@as(u32, place.y) * g.cell_h),
+ .w = @intCast(@as(u32, place.w) * g.cell_w),
+ .h = @intCast(@as(u32, place.h) * g.cell_h),
+ };
+ c.SDL_SetGPUScissor(pass, &clip);
+ const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = texture, .sampler = g.atlas_sampler };
c.SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1);
const binding = c.SDL_GPUBufferBinding{ .buffer = g.image_vbuf, .offset = idx * @sizeOf(ImageInstance) };
c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1);
c.SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0);
idx += 1;
}
+ const whole = c.SDL_Rect{
+ .x = 0,
+ .y = 0,
+ .w = @intCast(@as(u32, surface.cols) * g.cell_w),
+ .h = @intCast(@as(u32, surface.rows) * g.cell_h),
+ };
+ c.SDL_SetGPUScissor(pass, &whole);
}
fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void {
@@ -3044,7 +3124,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
- const image_count = prepareNativeImages(g, cmd, surface, sw, sh);
+ const image_count = prepareNativeImages(g, gpa, cmd, surface, sw, sh);
const cells: u32 = @as(u32, surface.cols) * surface.rows;
// ponytail: ×2, because the fractional-scroll pane is drawn a second time,
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
diff --git a/src/pdf.zig b/src/pdf.zig
index b06c85e8..a1c05681 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -72,6 +72,11 @@ pub const Render = struct {
height: usize,
};
+pub const PageSize = struct {
+ width: f32,
+ height: f32,
+};
+
pub const SearchQuad = struct {
quad: Quad,
/// Zero-based logical hit. One hit may have several oriented quads.
@@ -150,6 +155,17 @@ pub const Document = struct {
document.* = undefined;
}
+ /// Return crop/rotation-aware page dimensions without allocating pixels.
+ pub fn pageSize(document: *Document, page: usize) !PageSize {
+ const page_number = try document.checkedPage(page);
+ var size: c.pardes_pdf_page_size = std.mem.zeroes(c.pardes_pdf_page_size);
+ if (c.pardes_pdf_get_page_size(document.handle, page_number, &size) != c.PARDES_PDF_OK or
+ !std.math.isFinite(size.width) or !std.math.isFinite(size.height) or
+ size.width <= 0 or size.height <= 0)
+ return error.BadPageSize;
+ return .{ .width = size.width, .height = size.height };
+ }
+
/// Render one zero-based page, bounded before allocation. MuPDF supplies
/// row-strided RGB; Pardes' attachment boundary is packed straight RGBA.
pub fn render(document: *Document, gpa: std.mem.Allocator, page: usize) !Render {
diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c
index 320919b1..0fcf74c4 100644
--- a/src/pdf_bridge.c
+++ b/src/pdf_bridge.c
@@ -231,6 +231,39 @@ pardes_pdf_close(pardes_pdf_document *document)
}
int
+pardes_pdf_get_page_size(
+ pardes_pdf_document *document,
+ int page_number,
+ pardes_pdf_page_size *out)
+{
+ fz_context *ctx;
+ fz_rect bounds;
+
+ if (document == NULL || out == NULL || page_number < 0 ||
+ page_number >= document->page_count)
+ return PARDES_PDF_ERROR;
+
+ memset(out, 0, sizeof(*out));
+ ctx = document->ctx;
+ fz_try(ctx)
+ {
+ pardes_pdf_cache_page(document, page_number, 0);
+ bounds = document->cached_bounds;
+ out->width = bounds.x1 - bounds.x0;
+ out->height = bounds.y1 - bounds.y0;
+ if (!(out->width > 0.0f) || !(out->height > 0.0f))
+ fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds");
+ }
+ fz_catch(ctx)
+ {
+ fz_report_error(ctx);
+ memset(out, 0, sizeof(*out));
+ return PARDES_PDF_ERROR;
+ }
+ return PARDES_PDF_OK;
+}
+
+int
pardes_pdf_render(
pardes_pdf_document *document,
int page_number,
diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h
index 77b3dc58..cc706c33 100644
--- a/src/pdf_bridge.h
+++ b/src/pdf_bridge.h
@@ -23,6 +23,11 @@ typedef struct pardes_pdf_pixmap {
int components;
} pardes_pdf_pixmap;
+typedef struct pardes_pdf_page_size {
+ float width;
+ float height;
+} pardes_pdf_page_size;
+
typedef struct pardes_pdf_text {
void *handle;
const unsigned char *data;
@@ -105,6 +110,13 @@ enum {
pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count);
void pardes_pdf_close(pardes_pdf_document *document);
+/* Page dimensions in PDF points after crop/rotation, without rasterizing. */
+int pardes_pdf_get_page_size(
+ pardes_pdf_document *document,
+ int page,
+ pardes_pdf_page_size *out
+);
+
/*
* Start at dpi, raise the uniform scale until both optional minimum pixel
* dimensions are met, then clamp the page's longest side to max_dimension.
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 343452b5..90477d06 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -95,6 +95,8 @@ const Pty = struct {
const KittyPlacement = struct {
cell_x: u16,
cell_y: u16,
+ cell_cols: u16,
+ cell_rows: u16,
options: vaxis.Image.DrawOptions,
};
@@ -130,7 +132,7 @@ fn kittyPlacement(
const cell_h = terminalCellPixels(screen_pixel_h, screen_rows, 16);
const body_w = std.math.mul(u32, place.w, cell_w) catch return null;
const body_h = std.math.mul(u32, place.h, cell_h) catch return null;
- const geometry = pardes.image.nativeGeometry(
+ const geometry = place.native.geometry orelse pardes.image.nativeGeometry(
place.iw,
place.ih,
body_w,
@@ -140,28 +142,89 @@ fn kittyPlacement(
place.native.pan_y,
) orelse return null;
- // Every Kitty protocol crop field is u16. Reject an attachment which
- // the wire format cannot represent instead of truncating it into
- // another part of the page.
+ // Kitty has a top-left pixel offset but no destination bottom clip.
+ // Its missing c/r axis is rounded up to whole terminal cells, so a
+ // clipped fragment shorter than one row cannot be represented without
+ // painting the following theme gap. Conservatively keep only whole
+ // rows contained by geometry.dst and trim the source crop to the same
+ // scale. Cached page pixels remain unchanged; an unrepresentable tail
+ // is simply left as theme background.
+ const safe_rows_u32 = geometry.dst.h / cell_h;
+ if (safe_rows_u32 == 0) return null;
+ const safe_pixel_h = safe_rows_u32 * cell_h;
+ var safe_src_h: u32 = geometry.src.h;
+ var declared_cols: u32 = 0;
+ var declared_rows: u32 = 0;
+ switch (place.native.fit) {
+ .width => {
+ declared_cols = @intCast(@max(
+ @as(u64, 1),
+ (@as(u64, geometry.dst.w) + cell_w - 1) / cell_w,
+ ));
+ const declared_pixel_w = @as(u64, declared_cols) * cell_w;
+ const max_src_h: u32 = @intCast(
+ @as(u64, safe_pixel_h) * geometry.src.w / declared_pixel_w,
+ );
+ if (max_src_h == 0) return null;
+ safe_src_h = @min(safe_src_h, max_src_h);
+ const aspect_pixel_h: u32 = @intCast(
+ (@as(u64, safe_src_h) * declared_pixel_w + geometry.src.w - 1) /
+ geometry.src.w,
+ );
+ declared_rows = @intCast(
+ (@as(u64, aspect_pixel_h) + cell_h - 1) / cell_h,
+ );
+ },
+ .height => {
+ declared_rows = safe_rows_u32;
+ safe_src_h = @max(@as(u32, 1), @as(u32, @intCast(
+ @as(u64, safe_pixel_h) * geometry.src.h / geometry.dst.h,
+ )));
+ safe_src_h = @min(safe_src_h, geometry.src.h);
+ const aspect_pixel_w: u32 = @intCast(
+ (@as(u64, geometry.src.w) * safe_pixel_h + safe_src_h - 1) /
+ safe_src_h,
+ );
+ declared_cols = @intCast(
+ (@as(u64, aspect_pixel_w) + cell_w - 1) / cell_w,
+ );
+ },
+ .contain => unreachable,
+ }
+ if (declared_rows == 0 or declared_rows > safe_rows_u32) return null;
+
+ // Every Kitty protocol field is u16. Reject an attachment which the
+ // wire format cannot represent instead of truncating it.
const src_x = std.math.cast(u16, geometry.src.x) orelse return null;
const src_y = std.math.cast(u16, geometry.src.y) orelse return null;
const src_w = std.math.cast(u16, geometry.src.w) orelse return null;
- const src_h = std.math.cast(u16, geometry.src.h) orelse return null;
+ const src_h = std.math.cast(u16, safe_src_h) orelse return null;
const cell_x = std.math.cast(u16, geometry.dst.x / cell_w) orelse return null;
const cell_y = std.math.cast(u16, geometry.dst.y / cell_h) orelse return null;
if (cell_x >= place.w or cell_y >= place.h) return null;
const pixel_x = std.math.cast(u16, geometry.dst.x % cell_w) orelse return null;
const pixel_y = std.math.cast(u16, geometry.dst.y % cell_h) orelse return null;
+ const cell_cols = std.math.cast(u16, @min(
+ @as(u32, place.w - cell_x),
+ (@as(u64, pixel_x) + @as(u64, declared_cols) * cell_w + cell_w - 1) / cell_w,
+ )) orelse return null;
+ const cell_rows = std.math.cast(u16, @min(
+ @as(u32, place.h - cell_y),
+ (@as(u64, pixel_y) + declared_rows * cell_h + cell_h - 1) / cell_h,
+ )) orelse return null;
+ if (cell_cols == 0 or cell_rows == 0) return null;
return .{
.cell_x = cell_x,
.cell_y = cell_y,
+ .cell_cols = cell_cols,
+ .cell_rows = cell_rows,
.options = .{
.clip_region = .{ .x = src_x, .y = src_y, .width = src_w, .height = src_h },
.pixel_offset = if (pixel_x != 0 or pixel_y != 0) .{ .x = pixel_x, .y = pixel_y } else null,
.size = switch (place.native.fit) {
- .width => .{ .cols = place.w },
- .height => .{ .rows = place.h },
+ .width => .{ .cols = std.math.cast(u16, declared_cols) orelse return null },
+ .height => .{ .rows = std.math.cast(u16, declared_rows) orelse return null },
.contain => unreachable,
},
},
@@ -171,11 +234,70 @@ fn kittyPlacement(
}
}
+test "Kitty PDF fragments never declare pixels beyond their clipped bottom" {
+ if (comptime !pardes.pdf_enabled) return;
+
+ const base = pardes.ImagePlace{
+ .pane = 0,
+ .serial = 1,
+ .native = .{
+ .revision = 1,
+ .page = 0,
+ .fit = .width,
+ .geometry = .{
+ .src = .{ .w = 96, .h = 10 },
+ .dst = .{ .y = 195, .w = 304, .h = 29 },
+ },
+ },
+ .x = 0,
+ .y = 0,
+ .w = 38,
+ .h = 14,
+ .rgba = &.{},
+ .iw = 96,
+ .ih = 64,
+ };
+ const width = kittyPlacement(base, 80, 16, 640, 256) orelse
+ return error.MissingSafeKittyWidthFragment;
+ const width_clip = width.options.clip_region.?;
+ const width_size = width.options.size.?;
+ try std.testing.expectEqual(@as(u16, 5), width_clip.height.?);
+ try std.testing.expectEqual(@as(u16, 38), width_size.cols.?);
+ try std.testing.expectEqual(@as(u16, 3), width.options.pixel_offset.?.y);
+ const aspect_pixels = (@as(u32, width_size.cols.?) * 8 * width_clip.height.? +
+ width_clip.width.? - 1) / width_clip.width.?;
+ const inferred_rows = (aspect_pixels + 15) / 16;
+ try std.testing.expect(inferred_rows * 16 <= base.native.geometry.?.dst.h);
+
+ var height = base;
+ height.native.fit = .height;
+ height.native.geometry.?.src.h = 9;
+ const height_fragment = kittyPlacement(height, 80, 16, 640, 256) orelse
+ return error.MissingSafeKittyHeightFragment;
+ try std.testing.expectEqual(@as(u16, 1), height_fragment.options.size.?.rows.?);
+ try std.testing.expect(@as(u32, height_fragment.options.size.?.rows.?) * 16 <=
+ height.native.geometry.?.dst.h);
+
+ // There is no honest APC for less than one physical row: omitting it is
+ // preferable to painting three pixels of the following theme gap.
+ height.native.geometry.?.dst.h = 13;
+ try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null);
+}
+
fn kittyImageRepresentable(place: pardes.ImagePlace) bool {
return place.iw > 0 and place.ih > 0 and
place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16);
}
+fn surfaceHasKittyKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool {
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (comptime pardes.pdf_enabled) if (!kittyImageRepresentable(place)) continue;
+ if (place.cacheKey().eql(key)) return true;
+ }
+ return false;
+}
+
const PdfWheelTarget = struct { pane: usize, page: usize };
/// A native PDF's page geometry is invalid between `setPdfPage` and the next
@@ -253,12 +375,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// pane unless this is in the env BEFORE bash starts (the rc is too late)
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
- const KittyCached = struct { key: pardes.ImageCacheKey, image: vaxis.Image };
- var kitty_handles: [pardes.MAX_PANES]?KittyCached = @splat(null);
- defer for (&kitty_handles) |*slot| if (slot.*) |cached| {
- vx.freeImage(tty.writer(), cached.image.id);
- slot.* = null;
- };
+ var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa);
+ defer {
+ var iterator = kitty_handles.valueIterator();
+ while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
+ kitty_handles.deinit();
+ }
var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
// per-slot spawn generation: a reused pane id ignores the old shell's
// late pty_eof (which would otherwise close the NEW pty on that slot)
@@ -520,10 +642,9 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// `watch off` effects go into a queue nobody drains — drop the lot
// here. The new core emits its own `on`s as it builds its panes.
for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0);
- for (&kitty_handles) |*slot| if (slot.*) |cached| {
- vx.freeImage(tty.writer(), cached.image.id);
- slot.* = null;
- };
+ var image_iterator = kitty_handles.valueIterator();
+ while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
+ kitty_handles.clearRetainingCapacity();
nc.native_images = vx.caps.kitty_graphics;
core.deinit();
core = nc;
@@ -583,37 +704,32 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
tz_cells.end();
// Pixel attachments (kitty graphics): transmit once per pixel
// generation, then re-place every frame (placements aren't
- // persistent). A slot can be closed and reused for an unrelated pane,
- // and a live document can render new pixels, so neither the pane
- // number nor its lifetime serial is a complete cache key by itself.
- var kitty_seen: [pardes.MAX_PANES]bool = @splat(false);
+ // persistent). The map is keyed by pane lifetime + PDF page + pixel
+ // revision, so any number of short visible pages can coexist without
+ // aliasing a fixed terminal cache slot.
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
if (comptime pardes.pdf_enabled) {
if (!kittyImageRepresentable(place)) continue;
}
- kitty_seen[place.pane] = true;
- if (kitty_handles[place.pane]) |cached| if (!cached.key.eql(place.cacheKey())) {
- vx.freeImage(tty.writer(), cached.image.id);
- kitty_handles[place.pane] = null;
- };
- if (kitty_handles[place.pane] == null and vx.caps.kitty_graphics) {
+ const key = place.cacheKey();
+ if (!kitty_handles.contains(key) and vx.caps.kitty_graphics) {
const enc = std.base64.standard.Encoder;
if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| {
defer gpa.free(b64);
_ = enc.encode(b64, place.rgba);
if (vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle|
- kitty_handles[place.pane] = .{ .key = place.cacheKey(), .image = handle };
+ kitty_handles.put(key, handle) catch vx.freeImage(tty.writer(), handle.id);
} else |_| {}
}
- if (kitty_handles[place.pane]) |cached| {
+ if (kitty_handles.get(key)) |cached| {
if (comptime !pardes.pdf_enabled) {
const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h });
- cached.image.draw(child, .{ .scale = .contain }) catch {};
+ cached.draw(child, .{ .scale = .contain }) catch {};
} else {
if (place.native.fit == .contain) {
const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h });
- cached.image.draw(child, .{ .scale = .contain }) catch {};
+ cached.draw(child, .{ .scale = .contain }) catch {};
} else if (kittyPlacement(
place,
vx.screen.width,
@@ -624,10 +740,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
const child = win.child(.{
.x_off = @as(i17, place.x) + placement.cell_x,
.y_off = @as(i17, place.y) + placement.cell_y,
- .width = place.w - placement.cell_x,
- .height = place.h - placement.cell_y,
+ .width = placement.cell_cols,
+ .height = placement.cell_rows,
});
- cached.image.draw(child, placement.options) catch {};
+ cached.draw(child, placement.options) catch {};
}
}
}
@@ -635,10 +751,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// Toggling PETSCII or closing a pane removes its attachment from the
// Surface. Release the terminal-side image then, not merely when that
// numeric pane slot happens to be reused.
- for (&kitty_handles, kitty_seen) |*slot, seen| if (!seen) if (slot.*) |cached| {
- vx.freeImage(tty.writer(), cached.image.id);
- slot.* = null;
- };
+ var stale: std.ArrayList(pardes.ImageCacheKey) = .empty;
+ defer stale.deinit(gpa);
+ var image_iterator = kitty_handles.iterator();
+ while (image_iterator.next()) |entry|
+ if (!surfaceHasKittyKey(surface, entry.key_ptr.*))
+ stale.append(gpa, entry.key_ptr.*) catch {};
+ for (stale.items) |key| if (kitty_handles.fetchRemove(key)) |removed|
+ vx.freeImage(tty.writer(), removed.value.id);
if (surface.cursor) |cur| {
win.showCursor(cur.x, cur.y);
// insert = beam, everything else = the terminal's default shape