From ec19caddb60e748115adb562b46d007598dcdb95 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 21:28:49 -0300 Subject: render PDFs as a continuous vertical page strip --- src/gui/gui.zig | 174 ++++++--- src/pardes.zig | 1002 +++++++++++++++++++++++++++++++++++++++--------- src/pdf.zig | 16 + src/pdf_bridge.c | 33 ++ src/pdf_bridge.h | 12 + src/tty/tty.zig | 196 ++++++++-- test/image_harness.zig | 286 +++++++------- 7 files changed, 1299 insertions(+), 420 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 @@ -230,6 +230,39 @@ pardes_pdf_close(pardes_pdf_document *document) free(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, 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 diff --git a/test/image_harness.zig b/test/image_harness.zig index f51839ae..4b1c527c 100644 --- a/test/image_harness.zig +++ b/test/image_harness.zig @@ -234,78 +234,79 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) try h.expectRawContains("\x1b_Ga=p,i=", "first PDF page was not placed in the pane body"); try h.expectContains("pdf 1/4 width PdfFit", "PDF pane did not expose its initial width fit"); - // Fit-width is the initial PDF policy. It must use Kitty's explicit column - // scaling rather than Vaxis contain. The PDF shares this test session with - // the initial shell pane, making its body slightly narrower than the page: - // the full source is retained and vertically letterboxed by a pixel offset. + // Fit-width is the initial PDF policy. The short fixture pages make the + // first boundary visible immediately, so both page rasters must coexist. + // Page one fills its own document box instead of being letterboxed inside + // the whole pane viewport. + const initial_transmits = std.mem.count(u8, h.raw.items, transmit); + if (initial_transmits < 2) return error.KittyPdfBoundaryPageMissing; const width_apc = kittyPlacementApc(h.raw.items, 0) orelse return error.KittyPdfWidthPlacementMissing; + const width_clip_h = kittyParam(width_apc, 'h') orelse return error.KittyPdfWidthCropWrong; if (kittyParam(width_apc, 'x') != 0 or kittyParam(width_apc, 'y') != 0 or - kittyParam(width_apc, 'w') != 96) + kittyParam(width_apc, 'w') != 96 or width_clip_h == 0 or width_clip_h > 64 or + kittyParam(width_apc, 'c') == null) { std.debug.print("unexpected Kitty fit-width placement: {s}\n", .{width_apc}); return error.KittyPdfWidthCropWrong; } - const source_h = kittyParam(width_apc, 'h') orelse return error.KittyPdfWidthCropMissing; - const pixel_y = kittyParam(width_apc, 'Y') orelse return error.KittyPdfWidthLetterboxMissing; - if (source_h != 64 or pixel_y == 0 or kittyParam(width_apc, 'c') == null) { - std.debug.print("unexpected Kitty fit-width letterbox: {s}\n", .{width_apc}); - return error.KittyPdfWidthLetterboxWrong; - } - - // Placement-only state reuses the terminal image. Middle-click the - // visible PdfFit word in the PDF tag, wait for a fit-height placement - // with explicit rows and a full source image, and prove that no second - // transmit happened. - const transmit_before_fit = std.mem.count(u8, h.raw.items, transmit); - const fit_raw_start = h.raw.items.len; - const tag = try h.screenText(); - defer gpa.free(tag); - const fit_at = std.mem.indexOf(u8, tag, "PdfFit") orelse - return error.PdfFitMissing; - const line_at = std.mem.lastIndexOfScalar(u8, tag[0..fit_at], '\n'); - const mouse_col = fit_at - if (line_at) |at| at + 1 else 0; - const mouse_row = std.mem.count(u8, tag[0..fit_at], "\n"); - const click = try std.fmt.allocPrint( - arena, - "\x1b[<1;{d};{d}M\x1b[<1;{d};{d}m", - .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 }, - ); - try h.send(click); - const fit_deadline = eh.nowMs() + 3000; - while (eh.nowMs() < fit_deadline) { - _ = try h.pumpOnce(50); - if (kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') != null) break; - } - try h.expectContains("pdf 1/4 height PdfFit", "PdfFit did not expose the active height fit"); - const height_apc = kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') orelse - return error.KittyPdfHeightPlacementMissing; - const height_crop_w = kittyParam(height_apc, 'w') orelse return error.KittyPdfHeightPlacementWrong; - if (kittyParam(height_apc, 'x') != 0 or kittyParam(height_apc, 'y') != 0 or - height_crop_w == 0 or height_crop_w >= 96 or kittyParam(height_apc, 'h') != 64 or - kittyParam(height_apc, 'r') == null) - return error.KittyPdfHeightPlacementWrong; - if (std.mem.count(u8, h.raw.items, transmit) != transmit_before_fit) - return error.KittyPdfFitRetransmitted; - - // Same pane, different pixels: page navigation must delete the cached - // Kitty image and transmit the new revision, not keep showing page one. + const initial_aspect_h = (38 * 8 * width_clip_h + 95) / 96; + if (((initial_aspect_h + 15) / 16) * 16 > 202) + return error.KittyPdfWidthPlacementOverdraw; + + // A one-row document scroll changes placements, not pixel generations or + // the active page. This is the cache-reuse seam the old page-at-a-time + // harness could not exercise. + const placements_before = std.mem.count(u8, h.raw.items, "\x1b_Ga=p,i="); + const placement_start = h.raw.items.len; try h.send("j"); - const second_deadline = eh.nowMs() + 5000; - while (eh.nowMs() < second_deadline) { + const placement_deadline = eh.nowMs() + 2000; + while (eh.nowMs() < placement_deadline and + std.mem.count(u8, h.raw.items, "\x1b_Ga=p,i=") <= placements_before) _ = try h.pumpOnce(50); - const deleted = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") != null; - if (deleted and std.mem.count(u8, h.raw.items, transmit) >= 2 and h.contains("pdf 2/4 height PdfFit")) break; - } - try h.expectRawContains("\x1b_Ga=d,d=I,i=", "page navigation did not release the old Kitty PDF image"); - if (std.mem.count(u8, h.raw.items, transmit) < 2) return error.KittyPdfRetransmitMissing; - try h.expectContains("pdf 2/4 height PdfFit", "j did not retain height fit on PDF page two"); - - // Exercise the semantic seam too: return to page one, search an exact - // occurrence across the document, then let n Look the page-two result. - // That Look must change the page and therefore produce another pixel - // generation. - try h.send("k"); - try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "k did not return to PDF page one at height fit"); + if (std.mem.count(u8, h.raw.items, transmit) != initial_transmits) + return error.KittyPdfPlacementScrollRetransmitted; + try h.expectContains("pdf 1/4 width PdfFit", "one-row scroll snapped to another PDF page"); + + // The newly exposed page-two head is 29px tall and begins three pixels + // into a cell. Kitty infers an integer row count when only c is supplied; + // the crop must therefore stay within one 16px row, not ceil into the gap. + const tail_deadline = eh.nowMs() + 2000; + while (eh.nowMs() < tail_deadline and + kittyPlacementApcWithParam(h.raw.items, placement_start, 'Y') == null) + _ = try h.pumpOnce(50); + const tail_apc = kittyPlacementApcWithParam(h.raw.items, placement_start, 'Y') orelse + return error.KittyPdfSafeTailPlacementMissing; + const tail_cols = kittyParam(tail_apc, 'c') orelse return error.KittyPdfSafeTailPlacementWrong; + const tail_w = kittyParam(tail_apc, 'w') orelse return error.KittyPdfSafeTailPlacementWrong; + const tail_h = kittyParam(tail_apc, 'h') orelse return error.KittyPdfSafeTailPlacementWrong; + if (kittyParam(tail_apc, 'Y') != 3 or tail_cols != 38 or tail_w != 96) + return error.KittyPdfSafeTailPlacementWrong; + const tail_aspect_h = (tail_cols * 8 * tail_h + tail_w - 1) / tail_w; + const tail_declared_h = ((tail_aspect_h + 15) / 16) * 16; + if (tail_declared_h > 29) return error.KittyPdfTailOverpaintedGap; + + // A viewport step crosses the boundary continuously: page one leaves and + // is deleted, page two remains cached, and newly intersecting page three + // is transmitted. Distinct fixture heights identify the latter on wire. + const page_step_start = h.raw.items.len; + const page_three_transmit = "\x1b_Gf=32,s=96,v=67,i="; + try h.send("\x06"); + try h.expectWaitFor("pdf 2/4 width PdfFit", 5000, "continuous page-down did not enter PDF page two"); + const page_step_deadline = eh.nowMs() + 5000; + while (eh.nowMs() < page_step_deadline and + std.mem.indexOf(u8, h.raw.items[page_step_start..], page_three_transmit) == null) + _ = try h.pumpOnce(50); + if (std.mem.indexOf(u8, h.raw.items[page_step_start..], "\x1b_Ga=d,d=I,i=") == null) + return error.KittyPdfOffscreenPageNotReleased; + if (std.mem.indexOf(u8, h.raw.items[page_step_start..], page_three_transmit) == null) + return error.KittyPdfNewVisiblePageMissing; + if (std.mem.count(u8, h.raw.items, transmit) != initial_transmits) + return error.KittyPdfRetainedNeighborRetransmitted; + + // Search still addresses semantic pages inside the strip. Returning one + // viewport, then revealing page two's hit, creates a decorated revision. + try h.send("\x02"); + try h.expectWaitFor("pdf 1/4 width PdfFit", 5000, "continuous page-up did not return to PDF page one"); try h.send("/SECOND\r"); try h.expectWaitFor("pages.pdf:2:1 SECOND", 5000, "PDF search did not preserve the exact page-two query occurrence"); const before_look = std.mem.count(u8, h.raw.items, transmit); @@ -313,85 +314,30 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) const look_deadline = eh.nowMs() + 5000; while (eh.nowMs() < look_deadline) { _ = try h.pumpOnce(50); - if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/4 height PdfFit")) break; + if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/4 width PdfFit")) break; } - try h.expectContains("pdf 2/4 height PdfFit", "n did not Look the PDF search result at page two"); + try h.expectContains("pdf 2/4 width PdfFit", "n did not reveal the PDF search result at page two"); if (std.mem.count(u8, h.raw.items, transmit) <= before_look) return error.KittyPdfLookRetransmitMissing; - // Kitty turns a larger wheel offset into adjacent SGR wheel presses. The - // TTY shell may drain those presses in one input batch, but every native - // PDF page transition must be rastered before the next press consumes the - // new page's geometry. Distinct page heights make that ordering observable - // in the actual Kitty transmit headers: page 2 is 96x64, page 3 96x67, - // and page 4 96x69. This is also a direct one-detent check before the - // burst and a direction-reversal check after it. - const wheel_up = try std.fmt.allocPrint(arena, "\x1b[<64;{d};{d}M", .{ mouse_col + 1, mouse_row + 1 }); - const wheel_down = try std.fmt.allocPrint(arena, "\x1b[<65;{d};{d}M", .{ mouse_col + 1, mouse_row + 1 }); - const page_three_transmit = "\x1b_Gf=32,s=96,v=67,i="; - const page_four_transmit = "\x1b_Gf=32,s=96,v=69,i="; - - try h.send(wheel_up); - try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "wheel-up did not return to PDF page one"); - const single_start = h.raw.items.len; - try h.send(wheel_down); - try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "one wheel detent did not advance exactly one PDF page"); - try h.pump(100); - if (std.mem.indexOf(u8, h.raw.items[single_start..], page_three_transmit) != null) - return error.KittyPdfSingleWheelSkippedPage; - - try h.send(wheel_up); - try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "wheel-up did not reset the burst fixture"); - const burst_start = h.raw.items.len; - const forward_burst = try std.fmt.allocPrint( - arena, - "\x1b[<65;{d};{d}M\x1b[<65;{d};{d}M\x1b[<65;{d};{d}M", - .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 }, - ); - try h.send(forward_burst); - try h.expectWaitFor("pdf 4/4 height PdfFit", 8000, "forward Kitty wheel burst did not reach PDF page four"); - const forward_raw = h.raw.items[burst_start..]; - const page_two_at = std.mem.indexOf(u8, forward_raw, transmit) orelse - return error.KittyPdfBurstSkippedPageTwoRender; - const page_three_at = std.mem.indexOf(u8, forward_raw, page_three_transmit) orelse - return error.KittyPdfBurstSkippedPageThreeRender; - const page_four_at = std.mem.indexOf(u8, forward_raw, page_four_transmit) orelse - return error.KittyPdfBurstSkippedPageFourRender; - if (!(page_two_at < page_three_at and page_three_at < page_four_at)) - return error.KittyPdfBurstRenderOrderWrong; - - const reverse_start = h.raw.items.len; - const reverse_burst = try std.fmt.allocPrint( - arena, - "\x1b[<64;{d};{d}M\x1b[<64;{d};{d}M\x1b[<65;{d};{d}M\x1b[<64;{d};{d}M", - .{ - mouse_col + 1, mouse_row + 1, - mouse_col + 1, mouse_row + 1, - mouse_col + 1, mouse_row + 1, - mouse_col + 1, mouse_row + 1, - }, - ); - try h.send(reverse_burst); - try h.expectWaitFor("pdf 2/4 height PdfFit", 8000, "mixed-direction Kitty wheel burst did not return to PDF page two"); - const reverse_deadline = eh.nowMs() + 5000; - while (eh.nowMs() < reverse_deadline) { - const emitted = h.raw.items[reverse_start..]; - if (std.mem.count(u8, emitted, page_three_transmit) >= 2 and - std.mem.count(u8, emitted, transmit) >= 2) break; + // PdfFit changes only placement. The active decorated raster remains the + // same terminal image while height fit supplies an explicit row extent. + const before_fit = std.mem.count(u8, h.raw.items, transmit); + const fit_raw_start = h.raw.items.len; + try h.send(" tz"); + try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "PdfFit did not expose height fit"); + const fit_deadline = eh.nowMs() + 3000; + while (eh.nowMs() < fit_deadline and + kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') == null) _ = try h.pumpOnce(50); - } - const reverse_raw = h.raw.items[reverse_start..]; - const reverse_three_1 = std.mem.indexOf(u8, reverse_raw, page_three_transmit) orelse - return error.KittyPdfReverseBurstSkippedPageThreeRender; - const reverse_two_1 = std.mem.indexOfPos(u8, reverse_raw, reverse_three_1 + page_three_transmit.len, transmit) orelse - return error.KittyPdfReverseBurstSkippedPageTwoRender; - const reverse_three_2 = std.mem.indexOfPos(u8, reverse_raw, reverse_two_1 + transmit.len, page_three_transmit) orelse - return error.KittyPdfDirectionChangeSkippedPageThreeRender; - const reverse_two_2 = std.mem.indexOfPos(u8, reverse_raw, reverse_three_2 + page_three_transmit.len, transmit) orelse - return error.KittyPdfDirectionChangeSkippedPageTwoRender; - if (!(reverse_three_1 < reverse_two_1 and reverse_two_1 < reverse_three_2 and reverse_three_2 < reverse_two_2)) - return error.KittyPdfReverseBurstRenderOrderWrong; - - std.debug.print("PDF harness ok: Kitty fit/cache + page/search + metered wheel transitions\n", .{}); + const height_apc = kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') orelse + return error.KittyPdfHeightPlacementMissing; + const height_crop_w = kittyParam(height_apc, 'w') orelse return error.KittyPdfHeightPlacementWrong; + if (height_crop_w == 0 or height_crop_w > 96 or kittyParam(height_apc, 'h') != 64) + return error.KittyPdfHeightPlacementWrong; + if (std.mem.count(u8, h.raw.items, transmit) != before_fit) + return error.KittyPdfFitRetransmitted; + + std.debug.print("PDF harness ok: Kitty continuous boundary + exact cache + search/fit\n", .{}); } fn kittyPlacementApc(raw: []const u8, start: usize) ?[]const u8 { @@ -474,11 +420,23 @@ fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !v var h = try eh.Harness.initArgs(gpa, exe, 10, 20, pdf_path); defer h.deinit(); const fit_width = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); + const initial_green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000); + try expectThemeGap(capture_path, fit_width, initial_green); - // The narrow PDF pane makes fit-width vertically letterboxed. Fit-height - // keeps roughly the same colored width but fills/crops the full body - // height. These are GPU readback extents, so this exercises SDL's UV crop - // and destination rectangle rather than merely observing the page color. + // One row moves both native quads by the exact document distance. They + // remain together on screen, separated by the same theme-colored gap; a + // page-at-a-time implementation would replace red with green here. + _ = libc.unlink(capture_path); + try h.send("j"); + const moved_red = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); + const moved_green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000); + if (moved_red.max_y >= fit_width.max_y or moved_green.min_y >= initial_green.min_y) + return error.SdlPdfContinuousStepDidNotMove; + try expectThemeGap(capture_path, moved_red, moved_green); + + // Fit-height fills/crops the full body height. These are GPU readback + // extents, so this exercises SDL's UV crop and destination rectangle + // rather than merely observing the page color. _ = libc.unlink(capture_path); try h.send(" tz"); const fit_height = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); @@ -486,19 +444,28 @@ fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !v fit_width.width() - fit_height.width() else fit_height.width() - fit_width.width(); - if (fit_height.height() < fit_width.height() * 2 or width_delta > 8 or - fit_height.min_y >= fit_width.min_y) - { + if (fit_height.height() < fit_width.height() * 2 or width_delta > 8) { std.debug.print("SDL PDF fit extents did not change as expected: width={any}, height={any}\n", .{ fit_width, fit_height }); return error.SdlPdfFitGeometryWrong; } - // Remove the old capture before the key so merely rereading page one's - // file cannot satisfy page two. The app replaces this path atomically. + // In height fit one row exposes page two at the bottom but keeps page one + // active. Enough additional rows then move page one wholly offscreen and + // leave page two at the top, proving continuous traversal in GPU output. _ = libc.unlink(capture_path); try h.send("j"); + const height_red = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); + const height_green = try waitForCaptureBounds(&h, capture_path, pdf_green, 200, 5000); + try expectThemeGap(capture_path, height_red, height_green); + + _ = libc.unlink(capture_path); + try h.send("jjjjjjjj"); const green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000); - std.debug.print("PDF harness ok: SDL fit geometry + red->green ({d}->{d} pixels)\n", .{ fit_width.count, green.count }); + const final_capture = try readFile(gpa, capture_path); + defer gpa.free(final_capture); + const final_pixels = ppmPixels(final_capture) orelse return error.BadCapture; + if (countColor(final_pixels, pdf_red) != 0) return error.SdlPdfOffscreenPageStillDrawn; + std.debug.print("PDF harness ok: SDL continuous strip + theme gap + red->green ({d}->{d} pixels)\n", .{ fit_width.count, green.count }); } const ColorBounds = struct { @@ -542,6 +509,27 @@ fn waitForCaptureBounds( return error.PdfPageColorMissing; } +fn expectThemeGap(path: [:0]const u8, upper: ColorBounds, lower: ColorBounds) !void { + if (lower.min_y <= upper.max_y + 1) return error.SdlPdfGapMissing; + const gap = lower.min_y - upper.max_y - 1; + if (gap < 7 or gap > 9) return error.SdlPdfGapWrongSize; + const capture = try readFile(gpa, path); + defer gpa.free(capture); + const ppm = ppmView(capture) orelse return error.BadCapture; + const y = upper.max_y + 1 + gap / 2; + const x0 = upper.min_x + 2; + const x1 = @min(upper.max_x, lower.max_x) -| 2; + if (x0 > x1 or y >= ppm.height) return error.SdlPdfGapOutOfBounds; + const first = ppm.pixels[(y * ppm.width + x0) * 3 ..][0..3]; + if (std.mem.eql(u8, first, &pdf_red) or std.mem.eql(u8, first, &pdf_green)) + return error.SdlPdfGapCovered; + var x = x0 + 1; + while (x <= x1) : (x += 1) { + const pixel = ppm.pixels[(y * ppm.width + x) * 3 ..][0..3]; + if (!std.mem.eql(u8, pixel, first)) return error.SdlPdfGapNotThemeBackground; + } +} + const PpmView = struct { width: usize, height: usize, pixels: []const u8 }; fn colorBounds(ppm: PpmView, color: [3]u8) ?ColorBounds { -- cgit v1.3