summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig12
-rw-r--r--src/config.zig4
-rw-r--r--src/gui/gui.zig125
-rw-r--r--src/image.zig268
-rw-r--r--src/look.zig49
-rw-r--r--src/output_pane.zig7
-rw-r--r--src/pardes.zig1786
-rw-r--r--src/pdf.zig517
-rw-r--r--src/pdf_bridge.c686
-rw-r--r--src/pdf_bridge.h171
-rw-r--r--src/tty/tty.zig135
11 files changed, 3650 insertions, 110 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 6a8518ea..578a60dd 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -289,6 +289,18 @@ pub const FontSel = struct {
}
};
+/// Toggle a native PDF between the reading-oriented fit-width view and the
+/// whole-page-height view. Like Font on non-GUI builds, this declaration's
+/// `run` deliberately has the wrong shape when MuPDF is disabled: `all()` then
+/// omits it entirely, so the enum, Help and runtime binary contain no
+/// PdfFit.
+pub const PdfFit = struct {
+ pub const run = if (pardes.pdf_enabled) apply else {};
+ fn apply(c: Ctx) void {
+ c.p.togglePdfFit(c.pane);
+ }
+};
+
// The image pane's three renderer toggles. They used to be words the image tag
// printed and the execute dispatcher matched by hand; as builtins they are
// executable anywhere, pressable under SPC and listed by `SPC ?`, the whole
diff --git a/src/config.zig b/src/config.zig
index 714ec177..cee3613e 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -159,6 +159,10 @@ pub const leader_path = paths: {
table.set(.FontSel, "tf");
table.set(.Font, null);
}
+ // PdfFit exists only in MuPDF builds (its run declaration deliberately
+ // changes shape when the feature is off), so name its toggle path through
+ // the same comptime branch rather than making feature-off enums mention it.
+ if (pardes.pdf_enabled) table.set(.PdfFit, "tz");
// ...and the property EnumArray.init used to give for free: every builtin
// this build HAS is a builtin someone decided a path (or a null) for.
for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| {
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index c08c27fe..ef2422c5 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -198,10 +198,27 @@ const ImageInstance = extern struct {
};
const NativeImage = struct {
- serial: u32,
+ key: pardes.ImageCacheKey,
texture: *c.SDL_GPUTexture,
};
+fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) void {
+ if (comptime pardes.pdf_enabled) {
+ const cell_px_w: u16 = @intCast(@min(@max(1, cell_w), std.math.maxInt(u16)));
+ const cell_px_h: u16 = @intCast(@min(@max(1, cell_h), std.math.maxInt(u16)));
+ if (cols == core.screen_w and rows == core.screen_h and
+ cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h) return;
+ core.update(.{ .resize = .{
+ .cols = cols,
+ .rows = rows,
+ .cell_pixels = .{ .w = cell_px_w, .h = cell_px_h },
+ } });
+ } else {
+ if (cols == core.screen_w and rows == core.screen_h) return;
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ }
+}
+
// ---- touch: per-finger tracking + shared scroll/tap machines ----
const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 };
@@ -1303,8 +1320,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
_ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- if (cols != core.screen_w or rows != core.screen_h)
- core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ updateCoreResize(core, cols, rows, g.cell_w, g.cell_h);
// 7. render — the scroll animation steps LAST before it, while
// core.surface still holds the frame the last pass drew
stepScroll(&g, core, gpa);
@@ -1588,8 +1604,7 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void {
_ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- if (cols != core.screen_w or rows != core.screen_h)
- core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ updateCoreResize(core, cols, rows, g.cell_w, g.cell_h);
// 4. render — scroll animation last before it, see runNative
stepScroll(g, core, st.gpa);
_ = st.frame_arena.reset(.retain_capacity);
@@ -2762,11 +2777,42 @@ fn validImageBytes(place: pardes.ImagePlace) ?u32 {
return @intCast(bytes);
}
+const NativeBounds = struct { w: u32, h: u32 };
+
+fn nativeBounds(g: *const Gui, place: pardes.ImagePlace) ?NativeBounds {
+ const bound_w = std.math.mul(u32, place.w, g.cell_w) catch return null;
+ const bound_h = std.math.mul(u32, place.h, g.cell_h) catch return null;
+ return .{ .w = bound_w, .h = bound_h };
+}
+
+fn nativePlaceGeometry(g: *const Gui, place: pardes.ImagePlace) ?pardes.image.NativeGeometry {
+ if (comptime pardes.pdf_enabled) {
+ const bounds = nativeBounds(g, place) orelse return null;
+ return pardes.image.nativeGeometry(
+ place.iw,
+ place.ih,
+ bounds.w,
+ bounds.h,
+ place.native.fit,
+ place.native.pan_x,
+ place.native.pan_y,
+ );
+ }
+ return null;
+}
+
+fn nativePlaceDrawable(g: *const Gui, place: pardes.ImagePlace) bool {
+ if (comptime pardes.pdf_enabled) return nativePlaceGeometry(g, place) != null;
+ const bounds = nativeBounds(g, place) orelse return false;
+ const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h);
+ return fit.w != 0 and fit.h != 0;
+}
+
fn uploadNativeTexture(g: *Gui, 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.serial == place.serial) return;
+ if (cached.key.eql(place.cacheKey())) return;
releaseNativeImage(g, pane);
}
@@ -2817,10 +2863,10 @@ fn uploadNativeTexture(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, place: pardes.Imag
// 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] = .{ .serial = place.serial, .texture = texture };
+ g.native_images[pane] = .{ .key = place.cacheKey(), .texture = texture };
}
-/// Upload new pane generations and the small per-frame placement buffer.
+/// Upload new pixel generations and the small per-frame placement buffer.
/// Returns the number of image instances drawNativeImagesGpu will consume.
fn prepareNativeImages(
g: *Gui,
@@ -2842,7 +2888,7 @@ fn prepareNativeImages(
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
if (g.native_images[place.pane]) |cached| {
- if (cached.serial == place.serial) count += 1;
+ if (cached.key.eql(place.cacheKey()) and nativePlaceDrawable(g, place)) count += 1;
}
}
if (count == 0) return 0;
@@ -2855,28 +2901,41 @@ fn prepareNativeImages(
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
const cached = g.native_images[place.pane] orelse continue;
- if (cached.serial != place.serial) continue;
+ if (!cached.key.eql(place.cacheKey())) continue;
- const bound_w = @as(u32, place.w) * g.cell_w;
- const bound_h = @as(u32, place.h) * g.cell_h;
- const fit = pardes.image.contain(place.iw, place.ih, bound_w, bound_h);
- if (fit.w == 0 or fit.h == 0) continue;
- const px0 = @as(u32, place.x) * g.cell_w;
- const py0 = @as(u32, place.y) * g.cell_h;
- const x0 = (@as(f32, @floatFromInt(px0)) / win_w) * 2.0 - 1.0;
- const x1 = (@as(f32, @floatFromInt(px0 + fit.w)) / win_w) * 2.0 - 1.0;
- const y0 = 1.0 - (@as(f32, @floatFromInt(py0)) / win_h) * 2.0;
- const y1 = 1.0 - (@as(f32, @floatFromInt(py0 + fit.h)) / win_h) * 2.0;
- instances[idx] = .{
- .x0 = x0,
- .y0 = y0,
- .x1 = x1,
- .y1 = y1,
- .u0 = 0,
- .v0 = 0,
- .u1 = 1,
- .v1 = 1,
- };
+ 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 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,
+ .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,
+ .v1 = @as(f32, @floatFromInt(geometry.src.y + geometry.src.h)) / image_h,
+ };
+ } else {
+ const bounds = nativeBounds(g, place) orelse continue;
+ const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h);
+ if (fit.w == 0 or fit.h == 0) continue;
+ instances[idx] = .{
+ .x0 = (@as(f32, @floatFromInt(body_x)) / win_w) * 2.0 - 1.0,
+ .y0 = 1.0 - (@as(f32, @floatFromInt(body_y)) / win_h) * 2.0,
+ .x1 = (@as(f32, @floatFromInt(body_x + fit.w)) / win_w) * 2.0 - 1.0,
+ .y1 = 1.0 - (@as(f32, @floatFromInt(body_y + fit.h)) / win_h) * 2.0,
+ .u0 = 0,
+ .v0 = 0,
+ .u1 = 1,
+ .v1 = 1,
+ };
+ }
idx += 1;
}
c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer);
@@ -2897,7 +2956,8 @@ fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surf
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
const cached = g.native_images[place.pane] orelse continue;
- if (cached.serial != place.serial) continue;
+ if (!cached.key.eql(place.cacheKey())) continue;
+ if (!nativePlaceDrawable(g, place)) continue;
const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = cached.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) };
@@ -3190,8 +3250,7 @@ fn refitFont(g: *Gui, core: *pardes.Pardes) void {
_ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- if (cols != core.screen_w or rows != core.screen_h)
- core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ updateCoreResize(core, cols, rows, g.cell_w, g.cell_h);
// ...and a smooth scroll in flight is measured in the OLD grid: scroll_rect
// is a rect of the pane the last frame drew, and scroll_edge is a saved row
diff --git a/src/image.zig b/src/image.zig
index 1e7736c5..a9f0e227 100644
--- a/src/image.zig
+++ b/src/image.zig
@@ -5,6 +5,7 @@
const std = @import("std");
const zstbi = @import("zstbi");
const ghostty_vt = @import("ghostty-vt");
+const pdf_enabled = @import("pardes_config").mupdf;
pub const petscii = @import("petscii.zig");
/// the terminal's own 16 ANSI colors — what the `terminal` palette mode
@@ -25,46 +26,261 @@ const MAX_DIM: u32 = 1280;
pub const PaletteMode = enum { commodore, terminal };
-/// Fit an image inside a pixel rectangle without changing its aspect ratio or
-/// enlarging it. Native SDL uses this for the same "contain" policy the kitty
-/// placement uses. Keeping the arithmetic here makes the backend geometry
-/// testable without a GPU or an image-capable terminal.
-pub const Fit = struct { w: u32, h: u32 };
+/// How a native pixel attachment is placed in its pane body. `contain` is the
+/// historical image-pane policy: shrink only when necessary and keep the
+/// image at the top left. The axis-specific modes may enlarge, letterbox the
+/// unconstrained axis, or crop it when it overflows.
+pub const NativeFit = if (pdf_enabled) enum { contain, width, height } else enum { contain };
-pub fn contain(iw: usize, ih: usize, max_w: u32, max_h: u32) Fit {
- if (iw == 0 or ih == 0 or max_w == 0 or max_h == 0) return .{ .w = 0, .h = 0 };
+pub const PixelRect = struct {
+ x: u32 = 0,
+ y: u32 = 0,
+ w: u32 = 0,
+ h: u32 = 0,
+};
- const src_w: u64 = @intCast(iw);
- const src_h: u64 = @intCast(ih);
- const bound_w: u64 = max_w;
- const bound_h: u64 = max_h;
- if (src_w <= bound_w and src_h <= bound_h)
- return .{ .w = @intCast(src_w), .h = @intCast(src_h) };
+pub const PixelPoint = struct { x: u32, y: u32 };
- // Compare the two scale ratios without floating point. Whichever bound is
- // tighter determines one exact dimension; the other is rounded down so it
- // can never leak a pixel outside the pane body.
- if (bound_w * src_h <= bound_h * src_w) {
+/// Source pixels and their destination inside a pane body's pixel rectangle.
+/// Both rectangles are half-open. Rounding is always toward the inside, so a
+/// backend can draw this result without a backend-specific overflow clip.
+pub const NativeGeometry = struct {
+ src: PixelRect,
+ dst: PixelRect,
+
+ /// Map a destination pixel back into the retained source crop. This is the
+ /// same integer transform the texture rectangle describes; points in a
+ /// letterbox return null.
+ pub fn sourcePoint(self: NativeGeometry, x: u32, y: u32) ?PixelPoint {
+ if (self.src.w == 0 or self.src.h == 0 or self.dst.w == 0 or self.dst.h == 0) return null;
+ if (x < self.dst.x or y < self.dst.y) return null;
+ const dx = x - self.dst.x;
+ const dy = y - self.dst.y;
+ if (dx >= self.dst.w or dy >= self.dst.h) return null;
return .{
- .w = max_w,
- .h = @intCast(@max(1, src_h * bound_w / src_w)),
+ .x = self.src.x + @as(u32, @intCast(@as(u64, dx) * self.src.w / self.dst.w)),
+ .y = self.src.y + @as(u32, @intCast(@as(u64, dy) * self.src.h / self.dst.h)),
+ };
+ }
+};
+
+fn panOffset(overflow: u32, pan: u16) u32 {
+ if (overflow == 0 or pan == 0) return 0;
+ if (pan == std.math.maxInt(u16)) return overflow;
+ // Round, rather than truncate, so the midpoint is the visual midpoint.
+ return @intCast((@as(u64, overflow) * pan + std.math.maxInt(u16) / 2) / std.math.maxInt(u16));
+}
+
+fn fitWidth(src_w: u32, src_h: u32, body_w: u32, body_h: u32, pan_y: u16) ?NativeGeometry {
+ const scaled_h = @as(u64, src_h) * body_w;
+ if (scaled_h <= @as(u64, body_h) * src_w) {
+ const full_h: u32 = @intCast(@max(1, scaled_h / src_w));
+ return .{
+ .src = .{ .w = src_w, .h = src_h },
+ .dst = .{ .y = (body_h - full_h) / 2, .w = body_w, .h = full_h },
};
}
+
+ // Retain the largest whole source-pixel crop which cannot extend beyond
+ // the body at this scale. The sub-pixel remainder becomes at most a small
+ // centered letterbox instead of leaking into an adjacent cell in Kitty.
+ const crop_h_wide = @as(u64, body_h) * src_w / body_w;
+ if (crop_h_wide == 0) return null;
+ const crop_h: u32 = @min(src_h, @as(u32, @intCast(crop_h_wide)));
+ const dst_h: u32 = @min(body_h, @max(1, @as(u32, @intCast(@as(u64, crop_h) * body_w / src_w))));
return .{
- .w = @intCast(@max(1, src_w * bound_h / src_h)),
- .h = max_h,
+ .src = .{
+ .y = panOffset(src_h - crop_h, pan_y),
+ .w = src_w,
+ .h = crop_h,
+ },
+ .dst = .{ .y = (body_h - dst_h) / 2, .w = body_w, .h = dst_h },
};
}
-test "native image contain keeps aspect, bounds, and small-image size" {
+fn fitHeight(src_w: u32, src_h: u32, body_w: u32, body_h: u32, pan_x: u16) ?NativeGeometry {
+ const scaled_w = @as(u64, src_w) * body_h;
+ if (scaled_w <= @as(u64, body_w) * src_h) {
+ const full_w: u32 = @intCast(@max(1, scaled_w / src_h));
+ return .{
+ .src = .{ .w = src_w, .h = src_h },
+ .dst = .{ .x = (body_w - full_w) / 2, .w = full_w, .h = body_h },
+ };
+ }
+
+ const crop_w_wide = @as(u64, body_w) * src_h / body_h;
+ if (crop_w_wide == 0) return null;
+ const crop_w: u32 = @min(src_w, @as(u32, @intCast(crop_w_wide)));
+ const dst_w: u32 = @min(body_w, @max(1, @as(u32, @intCast(@as(u64, crop_w) * body_h / src_h))));
+ return .{
+ .src = .{
+ .x = panOffset(src_w - crop_w, pan_x),
+ .w = crop_w,
+ .h = src_h,
+ },
+ .dst = .{ .x = (body_w - dst_w) / 2, .w = dst_w, .h = body_h },
+ };
+}
+
+/// Compute native image placement without floating point. Source dimensions
+/// are limited to u32 because SDL's texture API and Kitty's crop parameters
+/// cannot describe anything larger. Kitty applies the stricter u16 check at
+/// its protocol boundary.
+pub fn nativeGeometry(
+ iw: usize,
+ ih: usize,
+ body_pixel_w: u32,
+ body_pixel_h: u32,
+ fit: NativeFit,
+ pan_x: u16,
+ pan_y: u16,
+) ?NativeGeometry {
+ if (comptime !pdf_enabled) {
+ return containGeometry(iw, ih, body_pixel_w, body_pixel_h);
+ }
+ if (iw == 0 or ih == 0 or body_pixel_w == 0 or body_pixel_h == 0 or
+ iw > std.math.maxInt(u32) or ih > std.math.maxInt(u32)) return null;
+
+ const src_w: u32 = @intCast(iw);
+ const src_h: u32 = @intCast(ih);
+ return switch (fit) {
+ .width => fitWidth(src_w, src_h, body_pixel_w, body_pixel_h, pan_y),
+ .height => fitHeight(src_w, src_h, body_pixel_w, body_pixel_h, pan_x),
+ .contain => blk: {
+ if (src_w <= body_pixel_w and src_h <= body_pixel_h) break :blk .{
+ .src = .{ .w = src_w, .h = src_h },
+ .dst = .{ .w = src_w, .h = src_h },
+ };
+
+ // Compare scale ratios without floating point. The tighter bound
+ // is exact; the other is rounded inward. Contain intentionally
+ // ignores pan and keeps the historical top-left placement.
+ const dst: PixelRect = if (@as(u64, body_pixel_w) * src_h <= @as(u64, body_pixel_h) * src_w)
+ .{
+ .w = body_pixel_w,
+ .h = @intCast(@max(1, @as(u64, src_h) * body_pixel_w / src_w)),
+ }
+ else
+ .{
+ .w = @intCast(@max(1, @as(u64, src_w) * body_pixel_h / src_h)),
+ .h = body_pixel_h,
+ };
+ break :blk .{ .src = .{ .w = src_w, .h = src_h }, .dst = dst };
+ },
+ };
+}
+
+/// The ordinary image-pane policy, separate from the PDF-only fit/pan
+/// machinery so feature-off backends retain their original contain-only path.
+pub fn containGeometry(iw: usize, ih: usize, max_w: u32, max_h: u32) ?NativeGeometry {
+ if (iw == 0 or ih == 0 or max_w == 0 or max_h == 0 or
+ iw > std.math.maxInt(u32) or ih > std.math.maxInt(u32)) return null;
+
+ const src_w: u32 = @intCast(iw);
+ const src_h: u32 = @intCast(ih);
+ if (src_w <= max_w and src_h <= max_h) return .{
+ .src = .{ .w = src_w, .h = src_h },
+ .dst = .{ .w = src_w, .h = src_h },
+ };
+ const dst: PixelRect = if (@as(u64, max_w) * src_h <= @as(u64, max_h) * src_w)
+ .{
+ .w = max_w,
+ .h = @intCast(@max(1, @as(u64, src_h) * max_w / src_w)),
+ }
+ else
+ .{
+ .w = @intCast(@max(1, @as(u64, src_w) * max_h / src_h)),
+ .h = max_h,
+ };
+ return .{ .src = .{ .w = src_w, .h = src_h }, .dst = dst };
+}
+
+/// Compatibility helper for callers which need only the old contain size.
+pub const Fit = struct { w: u32, h: u32 };
+
+pub fn contain(iw: usize, ih: usize, max_w: u32, max_h: u32) Fit {
+ const geometry = containGeometry(iw, ih, max_w, max_h) orelse
+ return .{ .w = 0, .h = 0 };
+ return .{ .w = geometry.dst.w, .h = geometry.dst.h };
+}
+
+test "native image contain keeps aspect, bounds, top-left, and small-image size" {
try std.testing.expectEqual(Fit{ .w = 40, .h = 20 }, contain(400, 200, 40, 40));
try std.testing.expectEqual(Fit{ .w = 20, .h = 40 }, contain(200, 400, 40, 40));
try std.testing.expectEqual(Fit{ .w = 17, .h = 9 }, contain(17, 9, 40, 40));
try std.testing.expectEqual(Fit{ .w = 0, .h = 0 }, contain(17, 9, 0, 40));
- // Awkward ratios stay inside both bounds rather than rounding one pixel
- // over them.
- const odd = contain(403, 211, 101, 47);
- try std.testing.expect(odd.w <= 101 and odd.h <= 47);
+
+ const odd = containGeometry(403, 211, 101, 47).?;
+ try std.testing.expectEqual(PixelRect{ .w = 89, .h = 47 }, odd.dst);
+ try std.testing.expectEqual(PixelRect{ .w = 403, .h = 211 }, odd.src);
+}
+
+test "native fit width handles portrait crop, pan extrema, and landscape letterbox" {
+ if (comptime !pdf_enabled) return;
+ const top = nativeGeometry(600, 1000, 800, 600, .width, 0, 0).?;
+ try std.testing.expectEqual(PixelRect{ .w = 600, .h = 450 }, top.src);
+ try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, top.dst);
+
+ const middle = nativeGeometry(600, 1000, 800, 600, .width, 0, 32768).?;
+ try std.testing.expectEqual(@as(u32, 275), middle.src.y);
+ const bottom = nativeGeometry(600, 1000, 800, 600, .width, 65535, 65535).?;
+ try std.testing.expectEqual(@as(u32, 550), bottom.src.y);
+
+ const landscape = nativeGeometry(1000, 600, 800, 600, .width, 0, 65535).?;
+ try std.testing.expectEqual(PixelRect{ .w = 1000, .h = 600 }, landscape.src);
+ try std.testing.expectEqual(PixelRect{ .y = 60, .w = 800, .h = 480 }, landscape.dst);
+}
+
+test "native fit height handles portrait letterbox and landscape crop" {
+ if (comptime !pdf_enabled) return;
+ const portrait = nativeGeometry(600, 1000, 800, 600, .height, 65535, 0).?;
+ try std.testing.expectEqual(PixelRect{ .w = 600, .h = 1000 }, portrait.src);
+ try std.testing.expectEqual(PixelRect{ .x = 220, .w = 360, .h = 600 }, portrait.dst);
+
+ const left = nativeGeometry(1000, 600, 800, 600, .height, 0, 0).?;
+ try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, left.src);
+ try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, left.dst);
+ const right = nativeGeometry(1000, 600, 800, 600, .height, 65535, 0).?;
+ try std.testing.expectEqual(@as(u32, 200), right.src.x);
+}
+
+test "native fit odd ratios round inward and map points through letterboxes and crops" {
+ if (comptime !pdf_enabled) return;
+ const width = nativeGeometry(403, 211, 101, 47, .width, 0, 32768).?;
+ try std.testing.expectEqual(PixelRect{ .y = 12, .w = 403, .h = 187 }, width.src);
+ try std.testing.expectEqual(PixelRect{ .w = 101, .h = 46 }, width.dst);
+ const mapped = width.sourcePoint(100, 45).?;
+ try std.testing.expect(mapped.x < width.src.x + width.src.w);
+ try std.testing.expect(mapped.y < width.src.y + width.src.h);
+ try std.testing.expect(width.sourcePoint(101, 46) == null);
+
+ const height = nativeGeometry(403, 211, 101, 47, .height, 32768, 0).?;
+ try std.testing.expectEqual(PixelRect{ .w = 403, .h = 211 }, height.src);
+ try std.testing.expectEqual(PixelRect{ .x = 6, .w = 89, .h = 47 }, height.dst);
+ try std.testing.expect(height.sourcePoint(5, 20) == null);
+ try std.testing.expect(height.sourcePoint(6, 20) != null);
+}
+
+test "native geometry rejects dimensions which its backend contract cannot represent" {
+ try std.testing.expect(containGeometry(0, 10, 20, 20) == null);
+ if (@bitSizeOf(usize) > 32) try std.testing.expect(containGeometry(@as(usize, std.math.maxInt(u32)) + 1, 10, 20, 20) == null);
+ if (comptime pdf_enabled) {
+ try std.testing.expect(nativeGeometry(10, 10, 0, 20, .width, 0, 0) == null);
+ if (@bitSizeOf(usize) > 32)
+ try std.testing.expect(nativeGeometry(@as(usize, std.math.maxInt(u32)) + 1, 10, 20, 20, .height, 0, 0) == null);
+ }
+}
+
+test "native geometry is total at extreme accepted aspect ratios" {
+ if (comptime !pdf_enabled) return;
+ const largest = std.math.maxInt(u32);
+ try std.testing.expect(nativeGeometry(1, largest, largest, 1, .width, 0, 65535) == null);
+ try std.testing.expect(nativeGeometry(largest, 1, 1, largest, .height, 65535, 0) == null);
+
+ const wide = nativeGeometry(largest, 1, largest, 1, .width, 0, 0).?;
+ try std.testing.expectEqual(PixelRect{ .w = largest, .h = 1 }, wide.dst);
+ const tall = nativeGeometry(1, largest, 1, largest, .height, 0, 0).?;
+ try std.testing.expectEqual(PixelRect{ .w = 1, .h = largest }, tall.dst);
}
/// call once at startup / exit (stb_image's allocator shim)
diff --git a/src/look.zig b/src/look.zig
index 9df2b485..4a8cb894 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -16,6 +16,7 @@ const builtin = @import("builtin");
const libc = std.c;
const pardes = @import("pardes.zig");
const config = @import("config.zig");
+const pdf_enabled = @import("pardes_config").mupdf;
const embedded_sources = if (pardes.platform == .web) @import("embedded_sources") else struct {
pub const Source = struct { path: []const u8, contents: []const u8 };
pub const all = [_]Source{};
@@ -164,10 +165,21 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" {
}
}
+/// A file-like Look target has a rendering kind only in MuPDF builds. The
+/// feature-off enum has no `pdf` tag at all, so `.pdf` is indistinguishable
+/// from any other ordinary file before it reaches the core.
+pub const FileKind = if (pdf_enabled) enum { text, pdf } else enum { text };
+
+pub const FileTarget = struct {
+ path: []const u8,
+ at: Spot,
+ kind: FileKind = .text,
+};
+
pub const Target = union(enum) {
none,
dir: []const u8, // resolved absolute path, in caller's buf
- file: struct { path: []const u8, at: Spot },
+ file: FileTarget,
image: struct { path: []const u8 },
url: []const u8,
/// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one
@@ -183,6 +195,36 @@ pub fn isImagePath(path: []const u8) bool {
return false;
}
+pub fn isPdfPath(path: []const u8) bool {
+ if (comptime !pdf_enabled) return false;
+ return std.ascii.endsWithIgnoreCase(path, ".pdf");
+}
+
+test "PDF file kinds exist only in MuPDF-enabled builds" {
+ try std.testing.expectEqual(pdf_enabled, isPdfPath("manual.PDF"));
+ try std.testing.expect(!isPdfPath("manual.pdf.txt"));
+ try std.testing.expectEqual(
+ pdf_enabled,
+ std.meta.stringToEnum(FileKind, "pdf") != null,
+ );
+ try std.testing.expect(std.meta.stringToEnum(std.meta.Tag(Target), "pdf") == null);
+}
+
+test ".pdf Look paths are ordinary files when MuPDF is disabled" {
+ if (!platform_has_fs) return;
+ var realbuf: [4096]u8 = undefined;
+ const target = resolve("docs/design.pdf", ".", &realbuf);
+ switch (target) {
+ .file => |file| {
+ if (comptime pdf_enabled)
+ try std.testing.expectEqual(FileKind.pdf, file.kind)
+ else
+ try std.testing.expectEqual(FileKind.text, file.kind);
+ },
+ else => return error.PdfDidNotResolveAsFile,
+ }
+}
+
/// Resolve a looked-at word against the pane's directory. `realbuf` must
/// outlive the returned Target (native paths point into it; web paths are
/// process-lifetime slices in the embedded source archive).
@@ -217,6 +259,11 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target
const rp = realpath(jz.ptr, realbuf) orelse return .none;
const resolved = std.mem.span(rp);
if (isDir(rp)) return .{ .dir = resolved };
+ if (comptime pdf_enabled) if (isPdfPath(resolved)) return .{ .file = .{
+ .path = resolved,
+ .at = pl.at,
+ .kind = .pdf,
+ } };
if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } };
return .{ .file = .{ .path = resolved, .at = pl.at } };
} else {
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 3308e22a..bd11a227 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -254,15 +254,18 @@ pub fn openJumps(p: *Pardes, id: usize) void {
for (p.jumps[0..p.njumps]) |j| {
const jp = p.panes[j.pane] orelse continue;
var idbuf: [16]u8 = undefined;
- const has_path = if (jp.file) |f| f.output == null else false;
+ const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null;
+ const has_path = if (jp.file) |f| f.output == null else pdf_path != null;
const loc: []const u8 = if (has_path)
- jp.file.?.path
+ (if (jp.file) |f| f.path else pdf_path.?)
else
std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch continue;
const what: []const u8 = if (jp.file) |f|
std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r")
else if (jp.image) |iv|
iv.path
+ else if (pdf_path) |path|
+ path
else
jp.cwdSlice();
// one line of a file can be the whole file: cut it, but never mid
diff --git a/src/pardes.zig b/src/pardes.zig
index 956b0e25..86dcb97f 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -31,6 +31,8 @@ const file_pane = @import("file_pane.zig");
const output_pane = @import("output_pane.zig");
const builtins = @import("builtins.zig");
pub const config = @import("config.zig");
+pub const pdf_enabled = @import("pardes_config").mupdf;
+const pdf_impl = if (pdf_enabled) @import("mupdf") else struct {};
pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
pub const lsp = @import("lsp/lsp.zig");
@@ -73,6 +75,512 @@ pub const Loc = struct {
col: u32,
};
+test "MuPDF pane renders, navigates, searches, and round-trips its 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].?;
+ try std.testing.expect(hasPdf(pane));
+ try std.testing.expect(pane.pdf.?.page_count > 1);
+ try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y);
+ try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w);
+ try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h);
+ const initial_tag = try p.tagText(p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null);
+ try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null);
+
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const first = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 1), first.nimages);
+ const first_place = first.images[0].?;
+ try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4);
+ try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit);
+
+ // The default reading view moves one visible cell row without replacing
+ // the page pixels. Fit/pan are placement state, not texture identity.
+ 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(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);
+
+ const row_pan = pane.pdf.?.pan_y;
+ pane.pdf.?.pan_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;
+ p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
+ try std.testing.expect(pane.pdf.?.pan_y >= half_pan);
+ 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' } });
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_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);
+ 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);
+
+ // 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.
+ const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse
+ return error.MissingPdfFitBuiltin;
+ const revision_before_toggle = pane.pdf.?.render_revision;
+ p.runBuiltin(fit_builtin, 0, "", null);
+ try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
+ try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y);
+ try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision);
+ const height_tag = try p.tagText(p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null);
+ try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null);
+ for (p.panes) |slot| {
+ const other = slot orelse continue;
+ if (hasPdf(other)) continue;
+ p.togglePdfFit(other); // pane-scoped and deliberately inert here
+ try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit);
+ break;
+ }
+
+ // 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;
+ 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;
+ pane.pdf.?.pan_x = 0;
+
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
+
+ const revision_before_search = pane.pdf.?.render_revision;
+ p.runSearch(0, "Pardes", .text, .top);
+ try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
+ try std.testing.expectEqual(revision_before_search, pane.pdf.?.render_revision);
+ const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
+ const results = p.panes[results_id].?.file.?.content;
+ try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null);
+ try std.testing.expect(p.searchStep(0, 1));
+ try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query);
+
+ // Search state is owned and untruncated, and changing pages invalidates
+ // only page-local state while retaining the document query.
+ const long_query = "a query deliberately longer than any tag display budget: " ++
+ "012345678901234567890123456789012345678901234567890123456789" ++
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ p.setPdfSearchQuery(&pane.pdf.?, long_query);
+ try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
+ const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0;
+ p.setPdfPage(pane, next_page);
+ try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query);
+
+ try p.dumpState();
+ const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?);
+ defer restored.deinit();
+ try std.testing.expect(hasPdf(restored.panes[0].?));
+ try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page);
+}
+
+test "PDF n/N addresses and reveals distinct MuPDF hits on one page" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 120,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+
+ var page_hits = try pv.document.search(gpa, 0, "Pardes");
+ defer page_hits.deinit(gpa);
+ try std.testing.expect(page_hits.hit_count >= 3);
+
+ p.runSearch(0, "Pardes", .text, .top);
+ const results_id = pane.search_pane orelse return error.MissingPdfSearchResults;
+ const rows = p.panes[results_id].?.file.?.content;
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null);
+
+ // Exaggerate the cell aspect only to make the three fixture hits occupy
+ // distinct fit-width crops. The search/reveal math must use the same
+ // reported metrics as placement, whatever a backend reports.
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = .{ .w = 16, .h = 4 },
+ } });
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ try std.testing.expect(p.searchStep(0, 1));
+ 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_results = pv.search_results orelse return error.MissingPdfPageSearch;
+ const first_quad = for (first_results.quads) |item| {
+ if (item.hit == 0) break item.quad;
+ } else return error.MissingFirstPdfHit;
+ const first_y = Pardes.normalizedPdfPixel(
+ (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4,
+ pv.ih,
+ );
+ const first_geometry = p.pdfGeometry(pane) orelse return error.MissingPdfGeometry;
+ try std.testing.expect(first_y >= first_geometry.src.y and
+ first_y < first_geometry.src.y + first_geometry.src.h);
+
+ try std.testing.expect(p.searchStep(0, 1));
+ try std.testing.expectEqual(@as(usize, 1), pv.search_hit);
+ _ = frame.reset(.retain_capacity);
+ const second = try p.render(frame.allocator());
+ try std.testing.expectEqual(revision, second.images[0].?.native.revision);
+
+ try std.testing.expect(p.searchStep(0, 1));
+ try std.testing.expectEqual(@as(usize, 2), pv.search_hit);
+ _ = 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);
+ 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;
+ } else return error.MissingThirdPdfHit;
+ const third_y = Pardes.normalizedPdfPixel(
+ (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4,
+ pv.ih,
+ );
+ const third_geometry = p.pdfGeometry(pane) orelse return error.MissingPdfGeometry;
+ try std.testing.expect(third_y >= third_geometry.src.y and
+ third_y < third_geometry.src.y + third_geometry.src.h);
+}
+
+test "PDF native mouse selection, Look, and highlights share page geometry" {
+ 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].?;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ // A host without Kitty/native pixels keeps the projected-text contract:
+ // j/k can still change pages and a body drag remains a generic selection.
+ const fallback = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), fallback.nimages);
+ p.update(.{ .key = .{ .cp = 'j' } });
+ try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
+ p.update(.{ .key = .{ .cp = 'k' } });
+ try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page);
+ const fallback_rect = p.rects[0];
+ const fallback_col = fallback_rect.x + config.GUTTER + 1;
+ const fallback_row = fallback_rect.y + BOX_H + 1;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } });
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+ try std.testing.expect(pane.pdf.?.selection == null);
+ pane.sel[sel_slot].state = .none;
+
+ p.native_images = true;
+ _ = frame.reset(.retain_capacity);
+ const plain = try p.render(frame.allocator());
+ const plain_revision = plain.images[0].?.native.revision;
+
+ var found = try pane.pdf.?.document.search(gpa, 0, "Pardes");
+ defer found.deinit(gpa);
+ const quad = found.quads[0].quad;
+ const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4;
+ const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4;
+ const pv = &pane.pdf.?;
+ const source_x: u32 = @intCast(@min(
+ pv.iw - 1,
+ @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(pv.iw)))),
+ ));
+ const source_y: u32 = @intCast(@min(
+ pv.ih - 1,
+ @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(pv.ih)))),
+ ));
+
+ // Bring the known word into the fit-width crop, then invert the shared
+ // source/destination geometry to the nearest body cell.
+ var geometry = p.pdfGeometry(pane).?;
+ if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) {
+ const overflow = @as(u32, @intCast(pv.ih)) - geometry.src.h;
+ const wanted = @min(overflow, source_y -| geometry.src.h / 2);
+ pv.pan_y = if (overflow == 0) 0 else @intCast(
+ (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
+ );
+ geometry = p.pdfGeometry(pane).?;
+ }
+ const pixel_x = geometry.dst.x + @as(u32, @intCast(
+ @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w,
+ ));
+ const pixel_y = geometry.dst.y + @as(u32, @intCast(
+ @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h,
+ ));
+ const r = p.rects[0];
+ const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w);
+ const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h);
+
+ var selected_col: ?u16 = null;
+ var selected_row: u16 = 0;
+ const nearby = [_]i32{ 0, -1, 1, -2, 2 };
+ find_word: for (nearby) |dy| for (nearby) |dx| {
+ const col: u16 = @intCast(std.math.clamp(
+ base_col + dx,
+ @as(i32, r.x + config.GUTTER),
+ @as(i32, r.x + r.w - 1),
+ ));
+ const row: u16 = @intCast(std.math.clamp(
+ base_row + dy,
+ @as(i32, r.y + BOX_H),
+ @as(i32, r.y + r.h - 1),
+ ));
+ if (!p.beginPdfSelection(pane, col, row)) continue;
+ if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) {
+ selected_col = col;
+ selected_row = row;
+ break :find_word;
+ }
+ };
+ const word_col = selected_col orelse return error.PdfMouseMappingMissedWord;
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(p.heldSelection(0).?, "Pardes") != null);
+
+ _ = frame.reset(.retain_capacity);
+ const selected_frame = try p.render(frame.allocator());
+ const selected_revision = selected_frame.images[0].?.native.revision;
+ try std.testing.expect(selected_revision != plain_revision);
+
+ // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the
+ // same texture generation instead of retransmitting identical pixels.
+ try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row));
+ try std.testing.expect(pv.render_tried);
+ _ = frame.reset(.retain_capacity);
+ const duplicate = try p.render(frame.allocator());
+ try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision);
+
+ // A native right-click resolves the same MuPDF-snapped word and feeds it
+ // to Look. Search highlights precede selection highlights so the live
+ // selection remains visually authoritative where they overlap.
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } });
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } });
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null);
+ p.resolvePdfSearch(pv);
+ const highlights = try p.pdfHighlights(pv);
+ try std.testing.expect(highlights.len > 1);
+ 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);
+ p.revealPdfSearch(pane, pv);
+ const revealed_pan = pv.pan_y;
+ try std.testing.expect(revealed_pan != std.math.maxInt(u16));
+ try std.testing.expectEqual(revision_before_reveal, pv.render_revision);
+ pv.pan_y = std.math.maxInt(u16);
+ 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;
+ _ = frame.reset(.retain_capacity);
+ const searched = try p.render(frame.allocator());
+ try std.testing.expect(searched.images[0].?.native.revision != selected_revision);
+
+ // Fit and viewport changes can crop a hit which was already revealed.
+ // Both rearm the placement-only reveal without forcing new page pixels.
+ const revision_before_geometry_change = searched.images[0].?.native.revision;
+ pv.search_reveal_pending = false;
+ p.togglePdfFit(pane);
+ try std.testing.expect(pv.search_reveal_pending);
+ pv.search_reveal_pending = false;
+ p.togglePdfFit(pane); // restore the reading-width geometry
+ try std.testing.expect(pv.search_reveal_pending);
+ pv.search_reveal_pending = false;
+ const original_cell_pixels = p.cell_pixels;
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = p.cell_pixels,
+ } });
+ try std.testing.expect(!pv.search_reveal_pending);
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = .{
+ .w = original_cell_pixels.w,
+ .h = original_cell_pixels.h + 1,
+ },
+ } });
+ try std.testing.expect(pv.search_reveal_pending);
+ _ = frame.reset(.retain_capacity);
+ const geometry_changed = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ revision_before_geometry_change,
+ geometry_changed.images[0].?.native.revision,
+ );
+ p.update(.{ .resize = .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .cell_pixels = original_cell_pixels,
+ } });
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+
+ // A fresh modal range supersedes native selection, and the select-button's
+ // no-drag click retains its longstanding "clear selection" contract.
+ p.update(.{ .key = .{ .cp = 'v' } });
+ try std.testing.expect(pv.selection == null);
+ try std.testing.expect(pane.vsel.active);
+ pane.vsel.active = false;
+ try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row));
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } });
+ try std.testing.expect(pv.selection == null);
+ try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row));
+
+ // Native drag events update MuPDF quads and copied text immediately, but
+ // keep the already transmitted page generation stable until release.
+ _ = frame.reset(.retain_capacity);
+ const before_drag = try p.render(frame.allocator());
+ const before_drag_revision = before_drag.images[0].?.native.revision;
+ const drag_col = @min(r.x + r.w - 1, word_col +| 12);
+ const drag_row = @min(r.y + r.h - 1, selected_row +| 4);
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .press,
+ .col = word_col,
+ .row = selected_row,
+ } });
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .drag,
+ .col = @min(drag_col, word_col +| 4),
+ .row = @min(drag_row, selected_row +| 2),
+ } });
+ _ = frame.reset(.retain_capacity);
+ const during_first_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision,
+ during_first_drag.images[0].?.native.revision,
+ );
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .drag,
+ .col = drag_col,
+ .row = drag_row,
+ } });
+ try std.testing.expect(p.drag.select.pdf.selection_changed);
+ _ = frame.reset(.retain_capacity);
+ const during_second_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision,
+ during_second_drag.images[0].?.native.revision,
+ );
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .release,
+ .col = drag_col,
+ .row = drag_row,
+ } });
+ try std.testing.expect(!pv.render_tried);
+ _ = frame.reset(.retain_capacity);
+ const committed_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ before_drag_revision + 1,
+ committed_drag.images[0].?.native.revision,
+ );
+ _ = frame.reset(.retain_capacity);
+ const stable_drag = try p.render(frame.allocator());
+ try std.testing.expectEqual(
+ committed_drag.images[0].?.native.revision,
+ stable_drag.images[0].?.native.revision,
+ );
+
+ const saved_query = try gpa.dupe(u8, pv.search_query);
+ defer gpa.free(saved_query);
+ p.setPdfPage(pane, 1);
+ try std.testing.expect(pv.selection == null);
+ try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len);
+ try std.testing.expectEqualStrings(saved_query, pv.search_query);
+}
+
+test "PDF native Exec release may delete its own pane safely" {
+ 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.?;
+ pv.selection_text = try gpa.dupe(u8, "Del");
+ pv.drag_anchor = .{ .x = 0.5, .y = 0.5 };
+ pv.drag_head = pv.drag_anchor;
+
+ p.pdfDragRelease(pane, .{
+ .id = 0,
+ .button = config.exec_button,
+ .pdf = .{ .native = true },
+ });
+ try std.testing.expect(p.panes[0] == null);
+}
+
+test "feature-off .pdf argv opens an ordinary file pane" {
+ if (comptime pdf_enabled or platform == .web) return;
+
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expect(pane.file != null);
+ try std.testing.expect(pane.image == null);
+ try std.testing.expect(!hasPdf(pane));
+}
+
const pane_tail = " " ++ config.pane_builtins_str;
const file_pane_tail = " " ++ config.file_pane_builtins_str;
@@ -89,6 +597,16 @@ const file_pane_tail = " " ++ config.file_pane_builtins_str;
// sits with the other two comptime folds (builtin_rows, the topbar check).
const Builtin = builtins.Builtin();
+test "PdfFit builtin and leader path follow the MuPDF feature gate" {
+ const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
+ try std.testing.expectEqual(pdf_enabled, maybe_fit != null);
+ if (pdf_enabled) {
+ const path = config.leader_path.get(maybe_fit.?) orelse
+ return error.MissingPdfFitLeaderPath;
+ try std.testing.expectEqualStrings("tz", path);
+ }
+}
+
/// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN
/// declarations rather than a table beside them — a new special key names
/// itself here, and a wrong name is impossible because there is only one. The
@@ -390,13 +908,50 @@ pub const Cell = struct {
}
};
+/// One generation of a pixel attachment. `serial` identifies the pane for its
+/// whole lifetime; `revision` identifies pixels rendered later by that same
+/// pane (for example, a different PDF page or zoom level). Backends must use
+/// both: pane slots are reused, while a live pane may replace its pixels.
+pub const ImageCacheKey = if (pdf_enabled) struct {
+ serial: u32,
+ revision: u32,
+
+ pub fn eql(a: @This(), b: @This()) bool {
+ return a.serial == b.serial and a.revision == b.revision;
+ }
+} else struct {
+ serial: u32,
+
+ pub fn eql(a: @This(), b: @This()) bool {
+ return a.serial == b.serial;
+ }
+};
+
+/// Native PDF sizing is deliberately semantic rather than a backend knob.
+/// Width mode is the document-reading view (the page may be panned vertically);
+/// height mode keeps one whole page-height visible and may pan wide pages.
+/// The type remains available to shared core code, while PdfView itself and
+/// the builtin which mutates it are compiled away with `-Dmupdf=false`.
+const PdfFitMode = if (pdf_enabled) enum { width, height } else void;
+
+/// Dynamic placement exists only for native PDF pages. Static image panes need
+/// only their pane identity, so feature-off builds carry a zero-bit payload.
+pub const NativePlacement = if (pdf_enabled) struct {
+ revision: u32 = 0,
+ fit: image.NativeFit = .contain,
+ pan_x: u16 = 0,
+ pan_y: u16 = 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 slots are reused. This generation makes a cached GPU texture or
+ /// 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,
+ native: NativePlacement = .{},
x: u16,
y: u16,
w: u16,
@@ -404,8 +959,57 @@ pub const ImagePlace = struct {
rgba: []const u8,
iw: usize,
ih: usize,
+
+ pub fn cacheKey(place: ImagePlace) ImageCacheKey {
+ if (comptime pdf_enabled)
+ return .{ .serial = place.serial, .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 }));
+ } else {
+ const first = ImageCacheKey{ .serial = 41 };
+ try std.testing.expect(first.eql(.{ .serial = 41 }));
+ try std.testing.expect(!first.eql(.{ .serial = 42 }));
+ }
+ try std.testing.expectEqual(pdf_enabled, @hasField(ImageCacheKey, "revision"));
+ try std.testing.expectEqual(pdf_enabled, @hasField(NativePlacement, "fit"));
+ try std.testing.expectEqual(pdf_enabled, @hasField(CellPixels, "w"));
+ if (comptime !pdf_enabled) {
+ try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey));
+ try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement));
+ try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels));
+ try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfDrag));
+ }
+}
+
+test "pixel attachment fit and pan do not invalidate cached pixels" {
+ if (comptime !pdf_enabled) return;
+ var place = ImagePlace{
+ .pane = 0,
+ .serial = 41,
+ .native = .{ .revision = 3 },
+ .x = 0,
+ .y = 0,
+ .w = 1,
+ .h = 1,
+ .rgba = &.{ 0, 0, 0, 255 },
+ .iw = 1,
+ .ih = 1,
+ };
+ const key = place.cacheKey();
+ place.native.fit = .width;
+ place.native.pan_x = 1234;
+ place.native.pan_y = 65535;
+ try std.testing.expect(key.eql(place.cacheKey()));
+}
+
/// The canonical interface: what a frame of pardes IS. The tty shell writes
/// these cells to vaxis one-to-one; the SDL shells rasterize them.
pub const Surface = struct {
@@ -570,10 +1174,25 @@ fn isPrefix(key: Key, cp: u21) bool {
return key.cp == cp and !key.ctrl and !key.alt;
}
+/// Pixel dimensions are meaningful only to the native PDF placement path.
+/// Keep resize events layout-only when that path is not part of the build.
+pub const CellPixels = if (pdf_enabled) struct {
+ w: u16 = 8,
+ h: u16 = 16,
+} else struct {};
+
pub const Event = union(enum) {
key: Key,
mouse: Mouse,
- resize: struct { cols: u16, rows: u16 },
+ resize: struct {
+ cols: u16,
+ rows: u16,
+ /// Physical pixels in one grid cell, present only when native PDF
+ /// placement is compiled in. Defaults keep headless/core callers
+ /// useful and give terminals which cannot report pixels the
+ /// conventional 1:2 cell aspect.
+ cell_pixels: CellPixels = .{},
+ },
output: struct { pane: u8, bytes: []const u8 },
eof: struct { pane: u8 },
/// a language query the shell ran on a worker has finished. `rows` is
@@ -784,6 +1403,75 @@ pub const Image = struct {
grid_ascii: bool = true,
};
+const PdfPoint = if (pdf_enabled) pdf_impl.Point else void;
+const PdfHighlight = if (pdf_enabled) pdf_impl.Highlight 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
+/// normal Pardes search semantics.
+const PdfView = if (pdf_enabled) struct {
+ path: []u8,
+ document: pdf_impl.Document,
+ page: usize = 0,
+ page_count: usize,
+ fit: PdfFitMode = .width,
+ /// Normalized 0..65535 pan positions. NativeGeometry maps the extrema
+ /// exactly onto the source overflow, independent of page or cell size.
+ pan_x: u16 = 0,
+ pan_y: u16 = 0,
+ rgba: []u8 = &.{},
+ /// Search/selection geometry changed without changing fit or pan. The next
+ /// draw transactionally regenerates this one RGBA buffer with MuPDF's baked
+ /// highlights; a second full-page buffer is never retained after the swap.
+ highlights_dirty: bool = false,
+ search_reveal_pending: bool = false,
+ search_results: ?pdf_impl.SearchResults = null,
+ selection: ?pdf_impl.Selection = null,
+ selection_text: []u8 = &.{},
+ selection_anchor: ?pdf_impl.Point = null,
+ selection_head: ?pdf_impl.Point = null,
+ drag_anchor: ?pdf_impl.Point = null,
+ drag_head: ?pdf_impl.Point = null,
+ iw: usize = 0,
+ ih: usize = 0,
+ render_tried: bool = false,
+ render_revision: u32 = 0,
+ text: []u8 = &.{},
+ text_tried: bool = false,
+ text_scroll: usize = 0,
+ /// Full, untruncated PDF query. +Search records only a display/Look copy;
+ /// this survives page navigation while page-local quads are rebuilt.
+ search_query: []u8 = &.{},
+ /// Zero-based logical MuPDF hit on the current page. Result rows encode
+ /// this as Spot.col + 1, letting same-page occurrences reveal distinctly.
+ search_hit: usize = 0,
+ /// Last viewport for which search_hit was revealed. Comparing this at
+ /// draw time covers layout/border changes without treating every frame
+ /// (or a manual pan) as a reason to snap back to the hit.
+ reveal_viewport_w: u32 = 0,
+ reveal_viewport_h: u32 = 0,
+ reveal_fit: PdfFitMode = .width,
+ reveal_viewport_valid: bool = false,
+} else void;
+
+const PdfSlot = if (pdf_enabled) ?PdfView else void;
+
+fn hasPdf(pane: *const Pane) bool {
+ return if (comptime pdf_enabled) pane.pdf != null else false;
+}
+
+fn hasPdfSelection(pane: *const Pane) bool {
+ return if (comptime pdf_enabled)
+ if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false
+ else
+ false;
+}
+
+test "feature-off PDF pane state is zero-sized" {
+ if (!pdf_enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfSlot));
+}
+
pub const Pane = struct {
vt: ghostty_vt.Terminal,
stream: ghostty_vt.TerminalStream,
@@ -805,6 +1493,7 @@ pub const Pane = struct {
greet: bool = false,
file: ?File = null,
image: ?Image = null,
+ pdf: PdfSlot = if (pdf_enabled) null else {},
msel: LineSel = .{},
vsel: CharSel = .{},
/// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary
@@ -920,7 +1609,17 @@ pub const Pane = struct {
}
pub fn isTerminal(pane: *const Pane) bool {
- return pane.file == null and pane.image == null;
+ return pane.file == null and pane.image == null and !hasPdf(pane);
+ }
+
+ pub fn pdfPath(pane: *const Pane) ?[]const u8 {
+ if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path;
+ return null;
+ }
+
+ pub fn pdfPage(pane: *const Pane) ?usize {
+ if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.page;
+ return null;
}
/// Surface row of shell row `g`. The edit buffer's lines stand in for the
@@ -947,6 +1646,7 @@ pub const Pane = struct {
/// current scroll offset: file top line, or the scrollback offset
pub fn scroll(pane: *Pane) i32 {
if (pane.file) |f| return @intCast(f.scroll);
+ if (comptime pdf_enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll);
return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset));
}
@@ -959,6 +1659,16 @@ pub const Pane = struct {
f.scroll = next;
f.syntax_dirty = true;
}
+ } else if (hasPdf(pane)) {
+ if (comptime pdf_enabled) {
+ const pv = &pane.pdf.?;
+ const max: i64 = @intCast(modal.lineCount(pv.text) -| 1);
+ pv.text_scroll = @intCast(std.math.clamp(
+ @as(i64, @intCast(pv.text_scroll)) + delta,
+ 0,
+ max,
+ ));
+ }
} else {
// the vt scrolls in SHELL rows; convert through the edit buffer
const off: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
@@ -978,6 +1688,10 @@ pub const Pane = struct {
// terminals overshoot harmlessly — the vt clamps at the live bottom
var to = pane.cur_row + margin;
if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1)));
+ if (comptime pdf_enabled) {
+ if (pane.pdf) |pv|
+ to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1)));
+ }
pane.scrollBy(@max(0, to - last));
}
// the horizontal mirror, files only: keep scroll_off columns of
@@ -996,12 +1710,13 @@ pub const Pane = struct {
/// the last navigable row: file -> last content line; terminal -> viewport bottom
fn lastNavRow(pane: *Pane) i32 {
if (pane.file) |*f| return @intCast(file_pane.nlines(pane.gpa, f) -| 1);
+ if (hasPdf(pane)) return @intCast(modal.lineCount(if (comptime pdf_enabled) pane.pdf.?.text else "") -| 1);
return pane.scroll() + @as(i32, pane.rows) - 1;
}
fn pinCursor(pane: *Pane) void {
if (pane.cur_pinned) return;
- if (pane.file != null) {
+ if (pane.file != null or hasPdf(pane)) {
pane.cur_row = pane.scroll();
pane.cur_col = 0;
} else {
@@ -1013,6 +1728,13 @@ pub const Pane = struct {
}
};
+const PdfDrag = if (pdf_enabled) struct {
+ native: bool = false,
+ /// Drag updates keep selection geometry/text live, but their expensive
+ /// baked raster highlight is committed once on release.
+ selection_changed: bool = false,
+} else struct {};
+
const Drag = union(enum) {
none,
/// `corner_idx` is what makes this a CORNER grab: the press landed on a
@@ -1042,7 +1764,15 @@ const Drag = union(enum) {
tag: struct { id: usize },
/// chorded: a 1-2/1-3 cut/paste chord fired during this left drag —
/// the drag's own release is then inert
- select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false },
+ select: struct {
+ id: usize,
+ button: Mouse.Button,
+ chorded: bool = false,
+ ctrl: bool = false,
+ /// This gesture began over a usable native PDF raster. The payload is
+ /// zero-bit when PDF support is absent.
+ pdf: PdfDrag = .{},
+ },
};
// The two border clamps, pulled out as plain arithmetic on plain numbers for
@@ -1140,6 +1870,7 @@ pub const Pardes = struct {
opts: Options,
screen_w: u16,
screen_h: u16,
+ cell_pixels: CellPixels = .{},
resize_count: usize = 0,
panes: [MAX_PANES]?*Pane = @splat(null),
@@ -1263,10 +1994,13 @@ pub const Pardes = struct {
if (opts.file) |path| {
// FILE argv boot: the doc focused in the left column, a terminal
// on the launch directory in the right (files left, shells right)
- _ = if (look.isImagePath(path))
- try p.openImageView(0, path)
- else
- try file_pane.open(p, 0, path, opts.file_line);
+ _ = initial_doc: {
+ if (comptime pdf_enabled) if (look.isPdfPath(path))
+ break :initial_doc try p.openPdfView(0, path, opts.file_line);
+ if (look.isImagePath(path))
+ break :initial_doc try p.openImageView(0, path);
+ break :initial_doc try file_pane.open(p, 0, path, opts.file_line);
+ };
const sh = try p.newPane(1);
sh.greet = true;
p.ncol = 2;
@@ -1350,6 +2084,18 @@ pub const Pardes = struct {
if (iv.raw.len > 0) p.gpa.free(iv.raw);
if (iv.grid.len > 0) p.gpa.free(iv.grid);
}
+ if (comptime pdf_enabled) {
+ if (pane.pdf) |*pv| {
+ p.gpa.free(pv.path);
+ if (pv.rgba.len > 0) p.gpa.free(pv.rgba);
+ 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);
+ if (pv.selection) |*selection| selection.deinit(p.gpa);
+ if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text);
+ pv.document.deinit();
+ }
+ }
if (pane.file) |*f| {
// a watched file is going away: tell the shell to drop it. The id
// comes from the slot, which every caller still has pointing at
@@ -1476,8 +2222,31 @@ pub const Pardes = struct {
pub fn update(p: *Pardes, ev: Event) void {
switch (ev) {
.resize => |sz| {
- p.screen_w = sz.cols;
- p.screen_h = sz.rows;
+ if (comptime pdf_enabled) {
+ var before: [MAX_PANES]?PdfViewport = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ if (pane.pdf != null) before[id] = p.pdfViewport(pane);
+ }
+ p.screen_w = sz.cols;
+ p.screen_h = sz.rows;
+ p.cell_pixels.w = @max(1, sz.cell_pixels.w);
+ p.cell_pixels.h = @max(1, sz.cell_pixels.h);
+ // Compare the effective per-pane pixel viewport, not the
+ // resize event itself: duplicate SIGWINCH notifications
+ // must not undo a reader's manual pan.
+ p.computeGeom();
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ if (pane.pdf) |*pv| {
+ if (!std.meta.eql(before[id], p.pdfViewport(pane)))
+ pv.search_reveal_pending = pv.search_query.len > 0;
+ }
+ }
+ } else {
+ p.screen_w = sz.cols;
+ p.screen_h = sz.rows;
+ }
p.resize_count += 1;
},
.output => |o| {
@@ -1557,6 +2326,11 @@ pub const Pardes = struct {
/// pane's.
fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
+ if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint(
+ arena,
+ "pdf {d}/{d} {s} PdfFit {s}",
+ .{ pv.page + 1, pv.page_count, @tagName(pv.fit), pv.path },
+ );
if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
if (pane.file) |f| return arena.dupe(u8, f.path);
return arena.dupe(u8, pane.cwdSlice());
@@ -1943,6 +2717,8 @@ pub const Pardes = struct {
/// the active modal selection as text (v range or x lines), if any
fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 {
+ if (comptime pdf_enabled) if (p.native_images) if (pane.pdf) |pv|
+ if (pv.selection != null and pv.selection_text.len > 0) return pv.selection_text;
if (pane.vsel.active) return p.vselText(pane);
if (!pane.msel.active) return null;
return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1));
@@ -1965,6 +2741,10 @@ pub const Pardes = struct {
};
if (k > 0 and (i == first or (k > 1 and i == p.active))) continue;
const t = p.panes[i] orelse continue;
+ if (comptime pdf_enabled) if (p.native_images) if (t.pdf) |pv| {
+ if (pv.selection != null and pv.selection_text.len > 0)
+ return pv.selection_text;
+ };
if (t.sel[sel_slot].state == .done) {
if (p.selectionText(t, t.sel[sel_slot]) catch null) |tx| return tx;
} else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) {
@@ -2097,7 +2877,8 @@ pub const Pardes = struct {
if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd;
pane.pinCursor();
- const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
+ const explicit = (p.native_images and hasPdfSelection(pane)) or
+ (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
if (explicit) {
if (p.currentSelText(pane)) |txt| {
pane.vsel.active = false;
@@ -2113,6 +2894,7 @@ pub const Pardes = struct {
p.runBuiltin(cmd, p.active, "", word);
return;
}
+ if (pane.mode == .normal and p.pdfNavigationKey(pane, key)) return;
// normal-mode `p`/`P` paste the core's yank register directly (helix:
// the default register, NOT the system clipboard — most terminals
// refuse the OSC 52 read, so a round trip would never come back).
@@ -2127,7 +2909,18 @@ pub const Pardes = struct {
return;
}
switch (pane.mode) {
- .normal => p.handleNormal(pane, key),
+ .normal => {
+ const id = p.active;
+ const serial = pane.serial;
+ p.handleNormal(pane, key);
+ // A new keyboard/modal range supersedes a mouse-native PDF
+ // selection. Otherwise currentSelText would keep returning the
+ // stale MuPDF text and make v/x appear to select nothing.
+ const live = p.panes[id] orelse return;
+ if (live.serial == serial and hasPdfSelection(live) and
+ (live.vsel.active or live.msel.active or live.nsel > 0))
+ p.clearPdfSelection(live);
+ },
.insert => {
if (hit(key, config.escape)) {
pane.mode = .normal;
@@ -2400,6 +3193,16 @@ pub const Pardes = struct {
row0: i32, // absolute row of lines[0]
};
+ fn ensurePdfText(p: *Pardes, pane: *Pane) ![]const u8 {
+ if (comptime !pdf_enabled) return error.PdfDisabled;
+ const pv = &(pane.pdf orelse return error.NotPdf);
+ if (!pv.text_tried) {
+ pv.text_tried = true;
+ pv.text = pv.document.pageText(p.gpa, pv.page) catch &.{};
+ }
+ return pv.text;
+ }
+
/// The lines the cursor moves over, absolute rows. File: all content lines.
/// Terminal: the whole history+active grid with the edit buffer's lines
/// standing in for the rows it covers, so motions ride the scrollback and
@@ -2421,6 +3224,13 @@ pub const Pardes = struct {
}
return .{ .lines = ls, .row0 = 0 };
}
+ if (hasPdf(pane)) {
+ const text = try p.ensurePdfText(pane);
+ var ls: std.ArrayList([]const u8) = .empty;
+ var it = std.mem.splitScalar(u8, text, '\n');
+ while (it.next()) |line| try ls.append(arena, line);
+ return .{ .lines = ls.items, .row0 = 0 };
+ }
const rows = try term_pane.shellRows(p, pane);
// the buffer may sit below the dump's last row (the grid's trailing
// blanks are trimmed off), so run the loop to whichever ends later
@@ -2463,6 +3273,7 @@ pub const Pardes = struct {
/// cursor-lines dump (scratch-arena backed, same lifetime as pl)
fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 {
if (pane.file) |f| return f.content;
+ if (hasPdf(pane)) return p.ensurePdfText(pane);
const arena = p.scratch.allocator();
var buf: std.ArrayList(u8) = .empty;
for (pl.lines, 0..) |ln, i| {
@@ -3708,6 +4519,76 @@ pub const Pardes = struct {
// ---- search (`/`, Find, n/N): results into an output buffer, look the rows ----
const Search = enum { text, find, grep };
+ const SearchStart = enum { top, cursor };
+
+ fn paneDir(pane: *const Pane) []const u8 {
+ if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/";
+ if (comptime pdf_enabled) if (pane.pdf) |pv|
+ return std.fs.path.dirname(pv.path) orelse "/";
+ return pane.cwdSlice();
+ }
+
+ const PdfSearchResult = struct { rows: usize = 0, anchor: ?usize = null };
+
+ /// Ask MuPDF for each page's logical hits, so the result list and the
+ /// quads used to highlight/reveal can never disagree. Locations spell
+ /// `path:PAGE:HIT`, reusing Spot.col as a one-based page-local hit id.
+ /// The compact snippet is the query itself: pairing MuPDF geometry back to
+ /// extracted lines would be heuristic, and a plausible wrong line is less
+ /// honest than the exact occurrence identity already in the address.
+ fn searchPdf(
+ p: *Pardes,
+ pane: *Pane,
+ pat: []const u8,
+ start: SearchStart,
+ out: *std.ArrayList(u8),
+ ) PdfSearchResult {
+ if (comptime !pdf_enabled) return .{};
+ const pv = &(pane.pdf orelse return .{});
+ p.setPdfSearchQuery(pv, pat);
+ const arena = p.scratch.allocator();
+ const shown = std.fs.path.basename(pv.path);
+ var result: PdfSearchResult = .{};
+ const max_hits = 512;
+ var snippet_len = @min(pat.len, 200);
+ while (snippet_len > 0 and snippet_len < pat.len and pat[snippet_len] & 0xc0 == 0x80)
+ snippet_len -= 1;
+ const snippet = pat[0..snippet_len];
+ for (0..pv.page_count) |page| {
+ if (result.rows >= max_hits) break;
+ var found = pv.document.search(p.gpa, page, pat) catch continue;
+ defer found.deinit(p.gpa);
+
+ // A native right-click selection identifies the occurrence the
+ // user is already on. Compare hit centers against its oriented
+ // selection quads; the next step then advances past that hit.
+ const cursor_hit: ?usize = if (start == .cursor and page == pv.page) cursor: {
+ const selection = pv.selection orelse break :cursor null;
+ for (found.quads) |item| {
+ const q = item.quad;
+ const center: PdfPoint = .{
+ .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4,
+ .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4,
+ };
+ if (selection.contains(center)) break :cursor item.hit;
+ }
+ break :cursor null;
+ } else null;
+
+ for (0..found.hit_count) |hit_index| {
+ if (result.rows >= max_hits) break;
+ if (start == .cursor and
+ (page < pv.page or (page == pv.page and cursor_hit != null and hit_index <= cursor_hit.?)))
+ result.anchor = result.rows;
+ const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
+ shown, page + 1, hit_index + 1, snippet,
+ }) catch return result;
+ out.appendSlice(arena, row) catch return result;
+ result.rows += 1;
+ }
+ }
+ return result;
+ }
/// `/` (and the Find builtin) on any pane: type the pattern into the tag
/// tail after `marker` — the existing modal one-line editor, visible while
@@ -3778,14 +4659,14 @@ pub const Pardes = struct {
/// there and the first step goes to the next one (acme's button-3 walking a
/// word). `/`, Find and Grep point at nothing, so their list is walked from
/// the top, which is also the only place a list of OTHER files could start.
- pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: enum { top, cursor }) void {
+ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) void {
const pane = p.panes[id] orelse return;
const pat = std.mem.trim(u8, pat_raw, " \t\r\n");
if (pat.len == 0) return;
const arena = p.scratch.allocator();
// where the pane lives: a file's directory, a shell's cwd — the walk
// root, and the directory the results buffer is named in.
- const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ const dir = paneDir(pane);
var out: std.ArrayList(u8) = .empty;
var nrows: usize = 0;
var anchor: ?usize = null;
@@ -3799,7 +4680,7 @@ pub const Pardes = struct {
var nroots: usize = 0;
for (p.panes) |slot| {
const pp = slot orelse continue;
- const d = if (pp.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pp.cwdSlice();
+ const d = paneDir(pp);
var covered = false;
var k: usize = 0;
while (k < nroots) {
@@ -3819,7 +4700,11 @@ pub const Pardes = struct {
nroots += 1;
}
for (roots[0..nroots]) |r| look.grep(arena, p.gpa, r, dir, pat, &out);
- } else if (kind == .find) look.find(arena, dir, pat, &out) else {
+ } else if (kind == .find) look.find(arena, dir, pat, &out) else if (hasPdf(pane)) {
+ const found = p.searchPdf(pane, pat, start, &out);
+ nrows = found.rows;
+ anchor = found.anchor;
+ } else {
const pl = p.paneCursorLines(pane) catch return;
// a real file names itself; a terminal or an output buffer has no path
const has_path = if (pane.file) |f| f.output == null else false;
@@ -4104,10 +4989,10 @@ pub const Pardes = struct {
/// you click anywhere and type there, which is what the insertion runs
/// did by padding at render time). Insert-mode callers pass their column;
/// everything else passes -1 and leaves the text alone.
- /// Null if the pane holds no editable text (an image).
+ /// Null if the pane holds no editable text (an image or PDF projection).
fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
if (pane.file) |f| return .{ .text = f.content, .row0 = 0 };
- if (pane.image != null) return null;
+ if (pane.image != null or hasPdf(pane)) return null;
const want_lo = @max(0, @min(lo, hi));
const want_hi = @max(want_lo, @max(lo, hi));
const fresh = pane.ovl == null;
@@ -4176,6 +5061,7 @@ pub const Pardes = struct {
const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void {
+ if (hasPdf(pane)) return;
pane.pinCursor();
// snapshot once per insert session (WITH the pre-insert selection) so
// `u` undoes the whole session and restores what was selected
@@ -5509,7 +6395,11 @@ pub const Pardes = struct {
// the answer is never in doubt here
_ = config.wheelTick(&p.wheel_guard, true);
const id = hovered orelse return;
- p.panes[id].?.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
+ const pane = p.panes[id].?;
+ if (hasPdf(pane))
+ p.pdfVerticalWheel(pane, if (m.button == .wheel_up) -1 else 1)
+ else
+ pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
.wheel_left, .wheel_right => {
if (m.kind != .press) return;
@@ -5520,10 +6410,13 @@ pub const Pardes = struct {
if (!config.wheelTick(&p.wheel_guard, false)) return;
const id = hovered orelse return;
const pane = p.panes[id].?;
- // ponytail: no right clamp — overscroll shows blank and the
- // next cursor move or left wheel pulls it back
- if (pane.file != null)
+ if (hasPdf(pane)) {
+ p.pdfHorizontalWheel(pane, if (m.button == .wheel_right) 1 else -1);
+ // ponytail: no right clamp — overscroll shows blank and the
+ // next cursor move or left wheel pulls it back
+ } else if (pane.file != null) {
pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols));
+ }
},
config.select_button => switch (m.kind) {
.press => {
@@ -5609,11 +6502,18 @@ pub const Pardes = struct {
const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
const v: i32 = @as(i32, mrow) - @as(i32, r.y);
pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
+ const pdf_native = if (comptime pdf_enabled) p.pdfNativeReady(pane) else false;
+ if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, false);
// Ctrl rides on the drag rather than firing here: the
// click does not place the modal cursor until RELEASE
// (dragRelease), and a goto asked at press time would
// answer about wherever the cursor happened to be.
- p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl } };
+ p.drag = .{ .select = .{
+ .id = id,
+ .button = config.select_button,
+ .ctrl = m.ctrl,
+ .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{},
+ } };
}
},
.drag => p.dragUpdate(mcol, mrow),
@@ -5664,7 +6564,21 @@ pub const Pardes = struct {
const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
const v: i32 = @as(i32, mrow) - @as(i32, r.y);
pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v };
- p.drag = .{ .select = .{ .id = id, .button = m.button } };
+ // Once a body gesture starts over a native PDF, keep
+ // it native even when the point misses a letterbox or
+ // selection fails. pdfDragRelease makes a null anchor
+ // inert instead of executing hidden projected text.
+ // Tag clicks remain ordinary text/builtin gestures.
+ const pdf_native = if (comptime pdf_enabled)
+ mrow >= r.y + BOX_H and p.pdfNativeReady(pane)
+ else
+ false;
+ if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, true);
+ p.drag = .{ .select = .{
+ .id = id,
+ .button = m.button,
+ .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{},
+ } };
}
},
.drag => p.dragUpdate(mcol, mrow),
@@ -5708,12 +6622,18 @@ pub const Pardes = struct {
d.cur_x = mcol;
d.cur_y = mrow;
},
- .select => |s| {
+ .select => |*s| {
if (p.panes[s.id]) |pane| {
const r = p.rects[s.id];
const b = @intFromEnum(s.button);
pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
+ if (comptime pdf_enabled) {
+ if (s.pdf.native) {
+ if (p.updatePdfSelection(pane, mcol, mrow, false))
+ s.pdf.selection_changed = true;
+ }
+ }
}
},
.tag => |d| {
@@ -5730,6 +6650,50 @@ pub const Pardes = struct {
}
}
+ fn pdfDragRelease(p: *Pardes, pane: *Pane, s: @FieldType(Drag, "select")) void {
+ if (comptime !pdf_enabled) return;
+ const pv = &(pane.pdf orelse return);
+ const b = @intFromEnum(s.button);
+ const sl = pane.sel[b];
+ pane.sel[b].state = .none; // native quads, not the projected text grid
+ // Every native drag keeps its geometry/text live while holding the
+ // baked highlight stable. Commit before chord/Exec/Look dispatch;
+ // those paths may clear or even delete the pane.
+ if (s.pdf.selection_changed) p.invalidatePdfRaster(pv);
+ if (s.chorded) {
+ clearPdfDrag(pv);
+ return;
+ }
+ if (s.button == config.select_button) {
+ const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
+ if (!dragged) {
+ p.clearPdfSelection(pane);
+ }
+ pane.cur_pinned = true;
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ pane.nsel = 0;
+ clearPdfDrag(pv);
+ return;
+ }
+
+ if (pv.drag_anchor == null or pv.selection_text.len == 0) {
+ clearPdfDrag(pv);
+ return;
+ }
+ const text = pv.selection_text;
+ // Exec can run Del and free `pane`; end every pv access before the
+ // builtin dispatch so no deferred cleanup writes through dead state.
+ clearPdfDrag(pv);
+ const arg = p.chord_arg;
+ p.chord_arg = null;
+ defer if (arg) |a| p.gpa.free(a);
+ const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd;
+ p.runBuiltin(cmd, s.id, "", p.withArg(text, arg));
+ }
+
fn dragRelease(p: *Pardes, button: Mouse.Button) void {
// a release of a button OTHER than the one driving a select drag is
// the middle+left chord's left release: the middle drag keeps going
@@ -5765,6 +6729,9 @@ pub const Pardes = struct {
},
.select => |s| {
const pane = p.panes[s.id] orelse return;
+ if (comptime pdf_enabled) {
+ if (s.pdf.native) return p.pdfDragRelease(pane, s);
+ }
if (s.chorded) {
// a cut/paste chord consumed this drag; the release is inert
pane.sel[@intFromEnum(s.button)].state = .none;
@@ -5893,6 +6860,16 @@ pub const Pardes = struct {
fn chordCutPaste(p: *Pardes, cut: bool) void {
const s = &p.drag.select;
const pane = p.panes[s.id] orelse return;
+ if (comptime pdf_enabled) if (pane.pdf) |pv| {
+ if (!s.chorded) {
+ s.chorded = true;
+ pane.sel[sel_slot].state = .none;
+ }
+ // A PDF is immutable: its Cut-side chord is Snarf (copy), while
+ // Paste has no document mutation to perform.
+ if (cut and pv.selection_text.len > 0) p.setYank(pv.selection_text);
+ return;
+ };
if (pane.mode == .tty) {
// tty: nothing can be cut — 1-2 stays inert; the pty owns the
// screen. 1-3 pastes like a terminal emulator: the click is
@@ -6143,7 +7120,7 @@ pub const Pardes = struct {
const src = p.panes[src_id] orelse return;
const src_h = p.rects[src_id].h;
const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
- const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1;
+ const cur: u16 = if (!src.isTerminal()) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1;
// cap keep so a content-full source still leaves the new pane a tag +
// a few body rows (an Alt-n from a full shell was born 0 rows tall)
const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3)));
@@ -6185,7 +7162,7 @@ pub const Pardes = struct {
var tty_id: usize = 0;
for (0..p.col_n[col]) |k| {
const cid = p.col_terms[col][k];
- if (p.panes[cid]) |ct| if (ct.file == null) {
+ if (p.panes[cid]) |ct| if (ct.isTerminal()) {
n_tty += 1;
tty_id = cid;
};
@@ -6206,7 +7183,7 @@ pub const Pardes = struct {
/// focus (execute keeps you where you were; look focuses).
fn ttyForDir(p: *Pardes, dir: []const u8) ?usize {
for (p.panes, 0..) |slot, i| if (slot) |tt| {
- if (tt.file != null or tt.image != null) continue;
+ if (!tt.isTerminal()) continue;
if (std.mem.eql(u8, tt.cwdSlice(), dir)) return i;
};
const free = p.freeSlot() orelse return null;
@@ -6266,6 +7243,579 @@ pub const Pardes = struct {
return pane;
}
+ fn openPdfView(p: *Pardes, id: usize, path: []const u8, page_one_based: usize) !*Pane {
+ if (comptime !pdf_enabled) return error.PdfDisabled;
+ var document = try pdf_impl.Document.open(path);
+ errdefer document.deinit();
+ const path_copy = try p.gpa.dupe(u8, path);
+ errdefer p.gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ const page = if (page_one_based > 0)
+ @min(page_one_based - 1, document.pages - 1)
+ else
+ 0;
+ pane.pdf = .{
+ .path = path_copy,
+ .document = document,
+ .page = page,
+ .page_count = document.pages,
+ };
+ pane.cur_pinned = true;
+ return pane;
+ }
+
+ /// Mark same-page pixels stale while retaining the one visible RGBA and
+ /// its dimensions. drawPdf swaps in a replacement only after MuPDF and the
+ /// allocation both succeed, so a failed highlight rerender never blanks a
+ /// page which was already visible.
+ fn invalidatePdfRaster(p: *Pardes, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ _ = p;
+ pv.render_tried = false;
+ }
+
+ fn dropPdfRaster(p: *Pardes, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ if (pv.rgba.len > 0) p.gpa.free(pv.rgba);
+ pv.rgba = &.{};
+ pv.render_tried = false;
+ }
+
+ fn dropPdfSearchResults(p: *Pardes, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ if (pv.search_results) |*results| results.deinit(p.gpa);
+ pv.search_results = null;
+ }
+
+ fn dropPdfSelection(p: *Pardes, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ if (pv.selection) |*selection| selection.deinit(p.gpa);
+ pv.selection = null;
+ if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text);
+ pv.selection_text = &.{};
+ pv.selection_anchor = null;
+ pv.selection_head = null;
+ }
+
+ fn clearPdfDrag(pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ pv.drag_anchor = null;
+ pv.drag_head = null;
+ }
+
+ fn clearPdfSelection(p: *Pardes, pane: *Pane) void {
+ if (comptime !pdf_enabled) return;
+ const pv = &(pane.pdf orelse return);
+ const changed = pv.selection != null or pv.selection_text.len > 0;
+ p.dropPdfSelection(pv);
+ 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.
+ 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;
+ }
+
+ /// Replace the full PDF query and discard only query-derived page state.
+ /// Resolution is lazy in drawPdf; fit and pan remain placement-only state.
+ fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) void {
+ if (comptime !pdf_enabled) return;
+ if (std.mem.eql(u8, pv.search_query, query)) return;
+ const owned = p.gpa.dupe(u8, query) catch return;
+ if (pv.search_query.len > 0) p.gpa.free(pv.search_query);
+ pv.search_query = owned;
+ pv.search_hit = 0;
+ p.dropPdfSearchResults(pv);
+ pv.highlights_dirty = query.len > 0;
+ pv.search_reveal_pending = query.len > 0;
+ p.invalidatePdfRaster(pv);
+ }
+
+ fn resolvePdfSearch(p: *Pardes, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ if (!pv.highlights_dirty) return;
+ pv.highlights_dirty = false;
+ p.dropPdfSearchResults(pv);
+ if (pv.search_query.len == 0) return;
+ const results = pv.document.search(p.gpa, pv.page, pv.search_query) catch return;
+ pv.search_hit = if (results.hit_count == 0)
+ 0
+ else
+ @min(pv.search_hit, results.hit_count - 1);
+ pv.search_results = results;
+ }
+
+ fn normalizedPdfPixel(value: f32, dimension: usize) u32 {
+ const scaled = std.math.clamp(value, 0, 1) *
+ @as(f32, @floatFromInt(dimension));
+ return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled))));
+ }
+
+ fn rearmPdfRevealForViewport(p: *Pardes, pane: *Pane, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ if (pv.search_query.len == 0) return;
+ const viewport = p.pdfViewport(pane) orelse return;
+ if (!pv.reveal_viewport_valid or
+ pv.reveal_viewport_w != viewport.pixel_w or
+ pv.reveal_viewport_h != viewport.pixel_h or
+ pv.reveal_fit != pv.fit)
+ pv.search_reveal_pending = true;
+ }
+
+ fn revealPdfSearch(p: *Pardes, pane: *Pane, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ if (!pv.search_reveal_pending) return;
+ const viewport = p.pdfViewport(pane) orelse return;
+ pv.reveal_viewport_w = viewport.pixel_w;
+ pv.reveal_viewport_h = viewport.pixel_h;
+ pv.reveal_fit = pv.fit;
+ pv.reveal_viewport_valid = true;
+ const results = pv.search_results orelse {
+ pv.search_reveal_pending = false;
+ return;
+ };
+ if (results.hit_count == 0 or results.quads.len == 0) {
+ pv.search_reveal_pending = false;
+ return;
+ }
+ pv.search_hit = @min(pv.search_hit, results.hit_count - 1);
+ const q = for (results.quads) |item| {
+ if (item.hit == pv.search_hit) break item.quad;
+ } else {
+ 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 (overflow == 0) return;
+ const wanted = @min(overflow, at -| crop / 2);
+ const pan: u16 = @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 {
+ 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;
+ }
+
+ /// Builtin entry point. Public only because builtins.zig is the command
+ /// registry; the PdfView representation remains private to this module.
+ pub fn togglePdfFit(p: *Pardes, pane: *Pane) void {
+ if (comptime !pdf_enabled) return;
+ _ = p;
+ const pv = &(pane.pdf orelse return);
+ pv.fit = if (pv.fit == .width) .height else .width;
+ pv.pan_x = 0;
+ pv.pan_y = 0;
+ pv.search_reveal_pending = pv.search_query.len > 0;
+ }
+
+ fn stepPdfPage(p: *Pardes, pane: *Pane, delta: i64) void {
+ if (comptime !pdf_enabled) return;
+ const pv = pane.pdf orelse return;
+ const current: i64 = @intCast(pv.page);
+ const last: i64 = @intCast(pv.page_count -| 1);
+ p.setPdfPage(pane, @intCast(std.math.clamp(current + delta, 0, last)));
+ }
+
+ const PdfViewport = struct {
+ pixel_w: u32,
+ pixel_h: u32,
+ };
+
+ const PdfPanAxis = enum { horizontal, vertical };
+ const PdfPanResult = enum { moved, edge, unavailable };
+
+ /// The image shells receive a cell rectangle while NativeGeometry works
+ /// in pixels. Keep this conversion in core state so navigation and drawing
+ /// agree even when the font metrics change at runtime.
+ fn pdfViewport(p: *const Pardes, pane: *const Pane) ?PdfViewport {
+ const r = for (p.panes, 0..) |slot, i| {
+ if (slot == pane) break p.rects[i];
+ } else return null;
+ const cols = r.w -| config.GUTTER;
+ const rows = r.h -| BOX_H;
+ if (cols == 0 or rows == 0) return null;
+ return .{
+ .pixel_w = @as(u32, cols) * @as(u32, p.cell_pixels.w),
+ .pixel_h = @as(u32, rows) * @as(u32, p.cell_pixels.h),
+ };
+ }
+
+ fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry {
+ 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,
+ viewport.pixel_w,
+ viewport.pixel_h,
+ switch (pv.fit) {
+ .width => .width,
+ .height => .height,
+ },
+ pv.pan_x,
+ pv.pan_y,
+ );
+ }
+
+ fn pdfNativeReady(p: *const Pardes, pane: *const Pane) bool {
+ return p.native_images and p.pdfGeometry(pane) != null;
+ }
+
+ /// Convert a grid mouse position to normalized page space through the
+ /// exact crop/destination transform used by both native backends. Presses
+ /// in a letterbox return null; a live drag clamps to the page edge so a
+ /// sweep can finish naturally outside the image.
+ fn pdfPointAt(
+ p: *const Pardes,
+ pane: *const Pane,
+ mcol: u16,
+ mrow: u16,
+ clamp_to_page: bool,
+ ) ?PdfPoint {
+ if (comptime !pdf_enabled) return null;
+ const pv = pane.pdf orelse return null;
+ const geometry = p.pdfGeometry(pane) orelse return null;
+ const r = for (p.panes, 0..) |slot, i| {
+ if (slot == pane) break p.rects[i];
+ } else return null;
+ const body_x = @as(i64, r.x + config.GUTTER);
+ const body_y = @as(i64, r.y + BOX_H);
+ var px = (@as(i64, mcol) - body_x) * p.cell_pixels.w + p.cell_pixels.w / 2;
+ var py = (@as(i64, mrow) - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2;
+ if (clamp_to_page) {
+ px = std.math.clamp(
+ px,
+ @as(i64, geometry.dst.x),
+ @as(i64, geometry.dst.x + geometry.dst.w - 1),
+ );
+ py = std.math.clamp(
+ py,
+ @as(i64, geometry.dst.y),
+ @as(i64, geometry.dst.y + geometry.dst.h - 1),
+ );
+ } else if (px < 0 or py < 0 or
+ px > std.math.maxInt(u32) or py > std.math.maxInt(u32)) return null;
+ const source = geometry.sourcePoint(@intCast(px), @intCast(py)) orelse return null;
+ const fw: f32 = @floatFromInt(pv.iw);
+ const fh: f32 = @floatFromInt(pv.ih);
+ return .{
+ .x = (@as(f32, @floatFromInt(source.x)) + 0.5) / fw,
+ .y = (@as(f32, @floatFromInt(source.y)) + 0.5) / fh,
+ };
+ }
+
+ const PdfSelectionUpdate = enum { failed, unchanged, changed };
+
+ /// Transactionally replace the word-snapped selection and its owned text.
+ /// Drags defer the expensive baked-highlight raster until release;
+ /// snap-word execute/look presses may still request an immediate update.
+ fn setPdfSelection(
+ p: *Pardes,
+ pane: *Pane,
+ start: PdfPoint,
+ end: PdfPoint,
+ invalidate_raster: bool,
+ ) PdfSelectionUpdate {
+ if (comptime !pdf_enabled) return .failed;
+ const pv = &(pane.pdf orelse return .failed);
+ if (pv.selection != null and
+ pv.selection_anchor != null and pv.selection_head != null and
+ pv.selection_anchor.?.x == start.x and pv.selection_anchor.?.y == start.y and
+ pv.selection_head.?.x == end.x and pv.selection_head.?.y == end.y) return .unchanged;
+ var selection = pv.document.select(p.gpa, pv.page, start, end) catch return .failed;
+ const text = pv.document.copySelection(
+ p.gpa,
+ pv.page,
+ selection.start,
+ selection.end,
+ ) catch {
+ selection.deinit(p.gpa);
+ return .failed;
+ };
+
+ p.dropPdfSelection(pv);
+ pv.selection = selection;
+ pv.selection_text = text;
+ pv.selection_anchor = start;
+ pv.selection_head = end;
+ if (invalidate_raster) p.invalidatePdfRaster(pv);
+ return .changed;
+ }
+
+ fn beginPdfDrag(
+ p: *Pardes,
+ pane: *Pane,
+ mcol: u16,
+ mrow: u16,
+ snap_word: bool,
+ ) bool {
+ if (comptime !pdf_enabled) return false;
+ const pv = &(pane.pdf orelse return false);
+ clearPdfDrag(pv);
+ const point = p.pdfPointAt(pane, mcol, mrow, false) orelse {
+ return false;
+ };
+ pv.drag_anchor = point;
+ pv.drag_head = point;
+ if (!snap_word) return true;
+ if (pv.selection) |selection| if (selection.contains(point)) {
+ return true;
+ };
+ if (p.setPdfSelection(pane, point, point, true) == .failed) {
+ // The old selection is intentionally transactional and survives,
+ // but this outside click must not execute that stale text.
+ clearPdfDrag(pv);
+ return false;
+ }
+ return true;
+ }
+
+ fn beginPdfSelection(p: *Pardes, pane: *Pane, mcol: u16, mrow: u16) bool {
+ return p.beginPdfDrag(pane, mcol, mrow, true);
+ }
+
+ fn updatePdfSelection(
+ p: *Pardes,
+ pane: *Pane,
+ mcol: u16,
+ mrow: u16,
+ invalidate_raster: bool,
+ ) bool {
+ if (comptime !pdf_enabled) return false;
+ const pv = &(pane.pdf orelse return false);
+ const anchor = pv.drag_anchor orelse return false;
+ const point = p.pdfPointAt(pane, mcol, mrow, true) orelse return false;
+ if (pv.drag_head) |head| if (head.x == point.x and head.y == point.y) return false;
+ const result = p.setPdfSelection(pane, anchor, point, invalidate_raster);
+ if (result != .failed) {
+ pv.drag_head = point;
+ }
+ return result == .changed;
+ }
+
+ fn pdfHighlights(p: *Pardes, pv: *PdfView) ![]const PdfHighlight {
+ if (comptime !pdf_enabled) return &.{};
+ var highlights: std.ArrayList(PdfHighlight) = .empty;
+ const arena = p.scratch.allocator();
+ if (pv.search_results) |results| {
+ try highlights.ensureUnusedCapacity(arena, results.quads.len);
+ for (results.quads) |item| highlights.appendAssumeCapacity(pdf_impl.Highlight.init(
+ item.quad,
+ .{ 0xff, 0xd5, 0x4f, 0x70 },
+ .search,
+ ));
+ }
+ if (pv.selection) |selection| {
+ try highlights.ensureUnusedCapacity(arena, selection.quads.len);
+ for (selection.quads) |quad| highlights.appendAssumeCapacity(pdf_impl.Highlight.init(
+ quad,
+ .{ 0x55, 0xa7, 0xff, 0x78 },
+ .selection,
+ ));
+ }
+ return highlights.items;
+ }
+
+ /// Move one normalized pan axis by a distance measured in DISPLAY pixels.
+ /// NativeGeometry supplies both the displayed and cropped extents, so this
+ /// remains exact for non-square cells and rounded page scaling.
+ fn panPdfPixels(
+ p: *Pardes,
+ pane: *Pane,
+ axis: PdfPanAxis,
+ direction: i8,
+ display_pixels: u32,
+ ) PdfPanResult {
+ if (comptime !pdf_enabled) return .unavailable;
+ const geometry = p.pdfGeometry(pane) orelse return .unavailable;
+ const pv = &(pane.pdf orelse return .unavailable);
+ const source_full: u32 = @intCast(switch (axis) {
+ .horizontal => pv.iw,
+ .vertical => pv.ih,
+ });
+ const crop = switch (axis) {
+ .horizontal => geometry.src.w,
+ .vertical => geometry.src.h,
+ };
+ const source_at = switch (axis) {
+ .horizontal => geometry.src.x,
+ .vertical => geometry.src.y,
+ };
+ const displayed = @max(@as(u32, 1), switch (axis) {
+ .horizontal => geometry.dst.w,
+ .vertical => geometry.dst.h,
+ });
+ const overflow = source_full -| crop;
+ if (overflow == 0 or
+ (direction < 0 and source_at == 0) or
+ (direction > 0 and source_at >= overflow)) return .edge;
+
+ const source_step = @max(
+ @as(u64, 1),
+ (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed,
+ );
+ const normalized_step: u32 = @intCast(@min(
+ @as(u64, std.math.maxInt(u16)),
+ @max(
+ @as(u64, 1),
+ (source_step * std.math.maxInt(u16) + overflow - 1) / overflow,
+ ),
+ ));
+ const position = switch (axis) {
+ .horizontal => &pv.pan_x,
+ .vertical => &pv.pan_y,
+ };
+ if (direction > 0) {
+ position.* = @intCast(@min(
+ @as(u32, std.math.maxInt(u16)),
+ @as(u32, position.*) + normalized_step,
+ ));
+ } else {
+ position.* -|= @intCast(normalized_step);
+ }
+ return .moved;
+ }
+
+ fn scaledPdfStep(base: u32, count: u32) u32 {
+ return @intCast(@min(
+ @as(u64, std.math.maxInt(u32)),
+ @as(u64, base) * @max(@as(u64, 1), count),
+ ));
+ }
+
+ /// 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);
+ }
+ },
+ }
+ }
+
+ 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) {
+ p.stepPdfPage(pane, direction);
+ return;
+ }
+ const rows: u32 = @intCast(@max(1, config.wheel_rows));
+ p.panPdfWidth(pane, direction, scaledPdfStep(p.cell_pixels.h, rows));
+ }
+
+ fn pdfHorizontalWheel(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) return;
+ const cols: u32 = @intCast(@max(1, config.wheel_cols));
+ _ = 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.
+ 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;
+ const row_down = hit(key, config.move_down);
+ const row_up = hit(key, config.move_up);
+ const half_down = hit(key, config.half_page_down);
+ const half_up = hit(key, config.half_page_up);
+ const page_down = hit(key, config.page_down);
+ const page_up = hit(key, config.page_up);
+ const down = row_down or half_down or page_down;
+ const up = row_up or half_up or page_up;
+ if (!down and !up) return false;
+ 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) {
+ const pages: i64 = @intCast(count);
+ p.stepPdfPage(pane, if (down) pages else -pages);
+ return true;
+ }
+ const viewport = p.pdfViewport(pane);
+ const base = if (row_down or row_up)
+ @as(u32, p.cell_pixels.h)
+ else if (half_down or half_up)
+ if (viewport) |v| @max(@as(u32, 1), v.pixel_h / 2) else @as(u32, p.cell_pixels.h)
+ else if (viewport) |v|
+ v.pixel_h
+ else
+ @as(u32, p.cell_pixels.h);
+ p.panPdfWidth(pane, direction, scaledPdfStep(base, count));
+ return true;
+ }
+
/// stack a fresh doc pane at the top of the LEFT column (acme convention:
/// files left, terminals right), halving ONLY the old top pane's slot so
/// the rest of the column keeps its sizes; then evict a leftover pristine
@@ -6296,6 +7846,20 @@ pub const Pardes = struct {
if (id >= MAX_PANES) return;
const pane = p.panes[id] orelse return;
p.active = id;
+ if (hasPdf(pane)) {
+ if (comptime pdf_enabled) {
+ if (at.line > 0) p.setPdfPage(pane, at.line - 1);
+ const pv = &pane.pdf.?;
+ // PDF search result rows use Spot.col as a one-based MuPDF
+ // logical hit id, not a text column. Rearming reveal moves
+ // only fit/pan; the page raster already contains every hit.
+ if (at.col > 0 and pv.search_query.len > 0) {
+ pv.search_hit = at.col - 1;
+ pv.search_reveal_pending = true;
+ }
+ }
+ return;
+ }
if (at.line == 0) return;
if (pane.file) |*f| {
if (at.line > file_pane.nlines(p.gpa, f)) return;
@@ -6352,6 +7916,10 @@ pub const Pardes = struct {
p.active = i;
return true;
};
+ if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) {
+ p.focusPaneLine(i, at);
+ return true;
+ };
const ff = if (tt.file) |*f| f else continue;
if (!std.mem.eql(u8, ff.path, path)) continue;
p.focusPaneLine(i, at);
@@ -6406,7 +7974,7 @@ pub const Pardes = struct {
const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane;
if (n > 0 and k == id) continue; // tried first, skip the rerun
const op = p.panes[k] orelse continue;
- const dir = if (op.file) |f| (std.fs.path.dirname(f.path) orelse "/") else op.cwdSlice();
+ const dir = paneDir(op);
for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand;
seen[nseen] = dir;
nseen += 1;
@@ -6472,6 +8040,13 @@ pub const Pardes = struct {
p.active = free;
},
.file => |target| {
+ if (comptime pdf_enabled) if (target.kind == .pdf) {
+ if (p.focusPaneByPath(target.path, target.at)) return;
+ const free = p.freeSlot() orelse return;
+ const nt = p.openPdfView(free, target.path, target.at.line) catch return;
+ p.placeDoc(id, free, nt);
+ return;
+ };
// focus an existing pane on this path (rescrolled), else open
if (p.focusPaneByPath(target.path, target.at)) return;
const free = p.freeSlot() orelse return;
@@ -6561,6 +8136,8 @@ pub const Pardes = struct {
// directory (found by cwd, else freshly forked).
const dst = (if (pane.file) |f|
p.ttyForDir(std.fs.path.dirname(f.path) orelse "/")
+ else if (hasPdf(pane))
+ p.ttyForDir(paneDir(pane))
else
id) orelse return null;
term_pane.padOutputBelowEdits(p, dst);
@@ -6602,7 +8179,7 @@ pub const Pardes = struct {
pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc
var src_id: ?usize = null;
- if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null) {
+ if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null or hasPdf(src)) {
src_id = from_id;
};
// Opened from somewhere that is NOT a doc (a shell, a results list):
@@ -6626,7 +8203,7 @@ pub const Pardes = struct {
const j = p.jumps[n - 1];
const pp = p.panes[j.pane] orelse continue;
if (pp.serial != j.serial) continue;
- if (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null) {
+ if (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp)) {
src_id = j.pane;
break;
}
@@ -6637,7 +8214,7 @@ pub const Pardes = struct {
// doc never claims a second column outranks knowing where you were, so
// the old slot-order scan stays as the fallback.
if (doc and src_id == null) for (p.panes, 0..) |sl, i| {
- if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null)) {
+ if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp))) {
src_id = i;
break;
};
@@ -6698,6 +8275,27 @@ pub const Pardes = struct {
.origin = if (f.output) |o| output_pane.word(o.from) else "",
.origin_arg = if (f.output) |*o| o.arg() else "",
},
+ } else if (hasPdf(pane)) blk: {
+ if (comptime !pdf_enabled) unreachable;
+ const pv = pane.pdf.?;
+ const bytes = look.readFile(arena, pv.path) catch "";
+ // Keep the dump schema backwards-compatible: a raster-backed
+ // document rides the existing binary image record, while its
+ // `scroll` field is the zero-based PDF page. Restore inspects
+ // the extension and reconstructs the semantic PDF pane.
+ break :blk .{
+ .kind = .image,
+ .tag = tag,
+ .body = body,
+ .scroll = pv.page,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .image = .{
+ .path = pv.path,
+ .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "",
+ },
+ };
} else if (pane.image) |iv| blk: {
const bytes = look.readFile(arena, iv.path) catch "";
break :blk .{
@@ -6864,15 +8462,36 @@ pub const Pardes = struct {
if (out == null) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } });
p.restoreTail(pane, src.tag);
},
- .image => {
+ .image => restore_image: {
const im = src.image.?;
- const path = try gpa.dupe(u8, im.path);
- errdefer gpa.free(path);
- const pane = try p.newDocPane(i);
const raw: []u8 = if (im.bytes_b64.len > 0)
dump.decodeBytes(gpa, im.bytes_b64) catch &.{}
else
&.{};
+ if (comptime pdf_enabled) if (look.isPdfPath(im.path)) {
+ if (p.openPdfView(i, im.path, src.scroll + 1) catch null) |pane| {
+ if (raw.len > 0) gpa.free(raw);
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ break :restore_image;
+ }
+ // If the source path is unavailable, retain its bytes
+ // as an ordinary file rather than dropping the pane.
+ const path = try gpa.dupe(u8, im.path);
+ errdefer gpa.free(path);
+ const content = if (raw.len > 0) raw else try gpa.dupe(u8, "");
+ errdefer if (content.len > 0) gpa.free(content);
+ const pane = try p.newDocPane(i);
+ pane.file = .{ .path = path, .content = content };
+ pane.cur_pinned = true;
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } });
+ break :restore_image;
+ };
+ const path = try gpa.dupe(u8, im.path);
+ errdefer gpa.free(path);
+ const pane = try p.newDocPane(i);
pane.image = .{ .path = path, .raw = raw };
pane.cols = @max(1, src.cols);
pane.rows = @max(1, src.rows);
@@ -6975,8 +8594,8 @@ pub const Pardes = struct {
const now: Loc = .{
.pane = @intCast(p.active),
.serial = pane.serial,
- .line = if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0,
- .col = if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0,
+ .line = if (pane.pdfPage()) |page| @intCast(page + 1) else if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0,
+ .col = if (pane.pdfPage() != null) 0 else if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0,
};
if (p.njumps > 0) {
const at = &p.jumps[p.jcur];
@@ -7206,6 +8825,9 @@ pub const Pardes = struct {
if (at.file) |*f| {
sb_off = f.scroll;
sb_total = file_pane.nlines(p.gpa, f);
+ } else if (at.pdfPage()) |page| {
+ sb_off = page;
+ sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0;
} else {
const sb = at.vt.screens.active.pages.scrollbar();
sb_off = sb.offset;
@@ -7345,6 +8967,15 @@ pub const Pardes = struct {
if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .y = r.y, .bar = pane.mode == .insert };
}
+ // 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;
+ }
+
// image pane: the picture fills the body — petscii glyph art into the
// cells, or a pixel attachment the shell places (kitty). Plain
// thumbless gutter so it reads like any other pane.
@@ -7527,6 +9158,10 @@ pub const Pardes = struct {
.total = file_pane.nlines(p.gpa, f),
.offset = f.scroll,
.len = pane.rows,
+ } else if (pane.pdfPage()) |page| .{
+ .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1,
+ .offset = page,
+ .len = 1,
} else blk: {
const gsb = pane.vt.screens.active.pages.scrollbar();
break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
@@ -7542,6 +9177,62 @@ pub const Pardes = struct {
}
}
+ /// Render the current PDF page once and attach its stable RGBA until page
+ /// navigation invalidates it. MuPDF rendering is bounded to a 1600-pixel
+ /// longest side in pdf.zig, independent of malicious page dimensions.
+ 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 (!pv.render_tried) {
+ pv.render_tried = true;
+ p.resolvePdfSearch(pv);
+ const rendered = render: {
+ const highlights = p.pdfHighlights(pv) catch break :render null;
+ const fresh = if (highlights.len > 0)
+ pv.document.renderWithHighlights(p.gpa, pv.page, highlights) catch break :render null
+ else
+ pv.document.render(p.gpa, pv.page) 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.rearmPdfRevealForViewport(pane, pv);
+ p.revealPdfSearch(pane, pv);
+ if (pv.rgba.len == 0 or r.h <= BOX_H) return false;
+ 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,
+ },
+ .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;
+ }
+
/// Image pane body: decode once (path via look, or dump-loaded raw bytes),
/// rebuild the petscii grid when the size/toggles changed, then either
/// blit the glyph art into cells or attach the pixels for the shell.
@@ -7627,12 +9318,27 @@ pub const Pardes = struct {
/// rows it covers — which is where a buffer holding more lines than those
/// rows pushes the output below it down the screen.
fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
- _ = p;
if (pane.image != null) {
const buf = try arena.alloc(u8, pane.rows -| 1);
@memset(buf, '\n');
return buf;
}
+ if (hasPdf(pane)) {
+ const text = try p.ensurePdfText(pane);
+ const scroll = if (comptime pdf_enabled) pane.pdf.?.text_scroll else 0;
+ var out: std.ArrayList(u8) = .empty;
+ var lines = std.mem.splitScalar(u8, text, '\n');
+ var row: usize = 0;
+ var wrote: usize = 0;
+ while (lines.next()) |line| : (row += 1) {
+ if (row < scroll) continue;
+ if (wrote >= pane.rows -| BOX_H) break;
+ if (wrote > 0) try out.append(arena, '\n');
+ try out.appendSlice(arena, line);
+ wrote += 1;
+ }
+ return out.items;
+ }
if (pane.file) |*f| return file_pane.bodyText(arena, pane, f);
return term_pane.bodyText(arena, pane);
}
diff --git a/src/pdf.zig b/src/pdf.zig
new file mode 100644
index 00000000..48b15cf1
--- /dev/null
+++ b/src/pdf.zig
@@ -0,0 +1,517 @@
+//! Exception-safe MuPDF runtime wrapper. MuPDF's public API throws with
+//! setjmp/longjmp; pdf_bridge.c catches every such throw before control returns
+//! to Zig. This module is present in native builds unless `-Dmupdf=false`.
+const std = @import("std");
+
+const c = @cImport({
+ @cInclude("pdf_bridge.h");
+});
+
+pub const max_render_dimension = 1600;
+/// MuPDF's per-document eviction cache ceiling. The owned RGBA render buffer
+/// is allocated separately by Pardes and does not count against this store.
+pub const store_limit_bytes: usize = c.PARDES_PDF_STORE_LIMIT_BYTES;
+
+test "MuPDF document cache has an explicit conservative ceiling" {
+ try std.testing.expectEqual(@as(usize, 64 * 1024 * 1024), store_limit_bytes);
+}
+
+/// Page-space geometry is normalized to the page bounds, not a render. It
+/// therefore survives DPI changes and can be shared by Kitty and SDL.
+pub const Point = c.pardes_pdf_point;
+pub const Quad = c.pardes_pdf_quad;
+
+pub const HighlightKind = enum(c_int) {
+ custom = c.PARDES_PDF_HIGHLIGHT_CUSTOM,
+ search = c.PARDES_PDF_HIGHLIGHT_SEARCH,
+ selection = c.PARDES_PDF_HIGHLIGHT_SELECTION,
+};
+
+/// RGBA is straight (not premultiplied). `kind` remains available to the UI as
+/// semantic metadata; the caller-supplied RGBA is authoritative for rendering.
+pub const Highlight = extern struct {
+ quad: Quad,
+ rgba: [4]u8,
+ kind: HighlightKind,
+
+ pub fn init(quad: Quad, rgba: [4]u8, kind: HighlightKind) Highlight {
+ return .{ .quad = quad, .rgba = rgba, .kind = kind };
+ }
+};
+
+comptime {
+ if (@sizeOf(Highlight) != @sizeOf(c.pardes_pdf_highlight) or
+ @alignOf(Highlight) != @alignOf(c.pardes_pdf_highlight) or
+ @offsetOf(Highlight, "quad") != @offsetOf(c.pardes_pdf_highlight, "quad") or
+ @offsetOf(Highlight, "rgba") != @offsetOf(c.pardes_pdf_highlight, "rgba") or
+ @offsetOf(Highlight, "kind") != @offsetOf(c.pardes_pdf_highlight, "kind"))
+ @compileError("Highlight must match pardes_pdf_highlight's C ABI");
+}
+
+pub const Render = struct {
+ rgba: []u8,
+ width: usize,
+ height: usize,
+};
+
+pub const SearchQuad = struct {
+ quad: Quad,
+ /// Zero-based logical hit. One hit may have several oriented quads.
+ hit: usize,
+};
+
+pub const SearchResults = struct {
+ quads: []SearchQuad,
+ hit_count: usize,
+
+ pub fn deinit(results: *SearchResults, gpa: std.mem.Allocator) void {
+ gpa.free(results.quads);
+ results.* = undefined;
+ }
+};
+
+pub const Selection = struct {
+ quads: []Quad,
+ /// Word-snapped endpoints; pass these to `Document.copySelection`.
+ start: Point,
+ end: Point,
+
+ /// Hit-test without flattening oriented text quads to axis-aligned boxes.
+ /// The bridge delegates to MuPDF's geometry predicate and cannot throw.
+ pub fn contains(selection: *const Selection, point: Point) bool {
+ if (!validPoint(point)) return false;
+ for (selection.quads) |quad| {
+ if (c.pardes_pdf_point_inside_quad(point, quad) != 0) return true;
+ }
+ return false;
+ }
+
+ pub fn deinit(selection: *Selection, gpa: std.mem.Allocator) void {
+ gpa.free(selection.quads);
+ selection.* = undefined;
+ }
+};
+
+test "oriented selection containment delegates to MuPDF quad geometry" {
+ const quads = [_]Quad{.{
+ .ul = .{ .x = 0.10, .y = 0.10 },
+ .ur = .{ .x = 0.70, .y = 0.20 },
+ .ll = .{ .x = 0.20, .y = 0.80 },
+ .lr = .{ .x = 0.80, .y = 0.90 },
+ }};
+ const selection = Selection{
+ .quads = @constCast(quads[0..]),
+ .start = quads[0].ul,
+ .end = quads[0].lr,
+ };
+ try std.testing.expect(selection.contains(.{ .x = 0.45, .y = 0.50 }));
+ try std.testing.expect(!selection.contains(.{ .x = 0.05, .y = 0.85 }));
+ try std.testing.expect(!selection.contains(.{ .x = -0.1, .y = 0.5 }));
+}
+
+pub const Document = struct {
+ handle: *c.pardes_pdf_document,
+ pages: usize,
+
+ pub fn open(path: []const u8) !Document {
+ var path_buf: [4096]u8 = undefined;
+ const path_z = std.fmt.bufPrintSentinel(&path_buf, "{s}", .{path}, 0) catch
+ return error.PathTooLong;
+ var page_count: c_int = 0;
+ const handle = c.pardes_pdf_open(path_z.ptr, &page_count) orelse
+ return error.OpenFailed;
+ if (page_count < 1) {
+ c.pardes_pdf_close(handle);
+ return error.EmptyDocument;
+ }
+ return .{ .handle = handle, .pages = @intCast(page_count) };
+ }
+
+ pub fn deinit(document: *Document) void {
+ c.pardes_pdf_close(document.handle);
+ document.* = undefined;
+ }
+
+ /// 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 {
+ return document.renderInternal(gpa, page, null);
+ }
+
+ /// Render and bake normalized oriented highlights through MuPDF's draw
+ /// device. An empty list takes the exact same plain-render path as render.
+ pub fn renderWithHighlights(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ highlights: []const Highlight,
+ ) !Render {
+ return document.renderInternal(gpa, page, highlights);
+ }
+
+ fn renderInternal(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ highlights: ?[]const Highlight,
+ ) !Render {
+ if (page >= document.pages or page > std.math.maxInt(c_int))
+ return error.PageOutOfRange;
+ var pixmap: c.pardes_pdf_pixmap = std.mem.zeroes(c.pardes_pdf_pixmap);
+ const status = if (highlights) |items|
+ if (items.len == 0)
+ c.pardes_pdf_render(
+ document.handle,
+ @intCast(page),
+ max_render_dimension,
+ &pixmap,
+ )
+ else
+ c.pardes_pdf_render_with_highlights(
+ document.handle,
+ @intCast(page),
+ max_render_dimension,
+ @ptrCast(items.ptr),
+ items.len,
+ &pixmap,
+ )
+ else
+ c.pardes_pdf_render(
+ document.handle,
+ @intCast(page),
+ max_render_dimension,
+ &pixmap,
+ );
+ if (status != c.PARDES_PDF_OK) return error.RenderFailed;
+ defer c.pardes_pdf_drop_pixmap(document.handle, pixmap.handle);
+
+ if (pixmap.samples == null or pixmap.width < 1 or pixmap.height < 1 or
+ pixmap.stride < 1 or pixmap.components != 3)
+ return error.BadPixmap;
+ const width: usize = @intCast(pixmap.width);
+ const height: usize = @intCast(pixmap.height);
+ const stride: usize = @intCast(pixmap.stride);
+ if (width > std.math.maxInt(usize) / 4 or
+ height > std.math.maxInt(usize) / (width * 4) or
+ height > std.math.maxInt(usize) / stride or
+ stride < width * 3)
+ return error.BadPixmap;
+
+ const rgba = try gpa.alloc(u8, width * height * 4);
+ errdefer gpa.free(rgba);
+ const samples: [*]const u8 = @ptrCast(pixmap.samples);
+ try expandRgb(rgba, samples[0 .. stride * height], width, height, stride);
+ return .{ .rgba = rgba, .width = width, .height = height };
+ }
+
+ /// Plain UTF-8-ish text projection for one zero-based page. MuPDF owns the
+ /// temporary buffer; callers receive an allocator-owned copy.
+ pub fn pageText(document: *Document, gpa: std.mem.Allocator, page: usize) ![]u8 {
+ if (page >= document.pages or page > std.math.maxInt(c_int))
+ return error.PageOutOfRange;
+ var text: c.pardes_pdf_text = std.mem.zeroes(c.pardes_pdf_text);
+ if (c.pardes_pdf_page_text(document.handle, @intCast(page), &text) != 0)
+ return error.TextFailed;
+ defer c.pardes_pdf_drop_text(document.handle, text.handle);
+ if (text.len == 0) return gpa.dupe(u8, "");
+ if (text.data == null) return error.BadText;
+ const bytes: [*]const u8 = @ptrCast(text.data);
+ return gpa.dupe(u8, bytes[0..text.len]);
+ }
+
+ /// Case-insensitive single-page search. MuPDF's iterative search owns a
+ /// retained reference to the cached structured-text page while running;
+ /// this wrapper copies every borrowed oriented quad before dropping it.
+ /// The C bridge doubles capacity up to 65,536 aggregate quads and returns
+ /// TooManyResults atomically rather than silently truncating beyond it.
+ pub fn search(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ needle: []const u8,
+ ) !SearchResults {
+ const page_number = try document.checkedPage(page);
+ if (std.mem.indexOfScalar(u8, needle, 0) != null)
+ return error.InvalidNeedle;
+ const needle_z = try gpa.dupeZ(u8, needle);
+ defer gpa.free(needle_z);
+
+ var found: c.pardes_pdf_search_result =
+ std.mem.zeroes(c.pardes_pdf_search_result);
+ const status = c.pardes_pdf_search_page(document.handle, page_number, needle_z.ptr, &found);
+ if (status == c.PARDES_PDF_LIMIT_EXCEEDED)
+ return error.TooManyResults;
+ if (status != c.PARDES_PDF_OK)
+ return error.SearchFailed;
+ defer c.pardes_pdf_drop_search_result(document.handle, found.handle);
+
+ if (found.quad_count != 0 and found.quads == null)
+ return error.BadGeometry;
+ const quads = try gpa.alloc(SearchQuad, found.quad_count);
+ errdefer gpa.free(quads);
+ if (found.quad_count != 0) {
+ const source: [*]const c.pardes_pdf_search_quad = @ptrCast(found.quads);
+ for (quads, source[0..found.quad_count]) |*dest, item| {
+ if (item.hit >= found.hit_count or !validQuad(item.quad))
+ return error.BadGeometry;
+ dest.* = .{ .quad = item.quad, .hit = item.hit };
+ }
+ }
+ return .{ .quads = quads, .hit_count = found.hit_count };
+ }
+
+ /// Word-snap two normalized page points and return allocator-owned,
+ /// orientation-preserving highlight geometry.
+ pub fn select(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ start: Point,
+ end: Point,
+ ) !Selection {
+ const page_number = try document.checkedPage(page);
+ if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint;
+
+ var selected: c.pardes_pdf_selection =
+ std.mem.zeroes(c.pardes_pdf_selection);
+ const status = c.pardes_pdf_select(document.handle, page_number, start, end, &selected);
+ if (status == c.PARDES_PDF_LIMIT_EXCEEDED)
+ return error.TooManyResults;
+ if (status != c.PARDES_PDF_OK)
+ return error.SelectionFailed;
+ defer c.pardes_pdf_drop_selection(document.handle, selected.handle);
+
+ if (selected.quad_count != 0 and selected.quads == null)
+ return error.BadGeometry;
+ if (!validPoint(selected.start) or !validPoint(selected.end))
+ return error.BadGeometry;
+ const quads = try gpa.alloc(Quad, selected.quad_count);
+ errdefer gpa.free(quads);
+ if (selected.quad_count != 0) {
+ const source: [*]const Quad = @ptrCast(selected.quads);
+ for (quads, source[0..selected.quad_count]) |*dest, item| {
+ if (!validQuad(item)) return error.BadGeometry;
+ dest.* = item;
+ }
+ }
+ return .{
+ .quads = quads,
+ .start = selected.start,
+ .end = selected.end,
+ };
+ }
+
+ /// Copy UTF-8 text between normalized page points. For word selection,
+ /// pass the snapped endpoints returned by `select`. MuPDF's temporary
+ /// fz_malloc string is always freed after making the allocator-owned copy.
+ pub fn copySelection(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ start: Point,
+ end: Point,
+ ) ![]u8 {
+ const page_number = try document.checkedPage(page);
+ if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint;
+
+ var text: c.pardes_pdf_owned_text =
+ std.mem.zeroes(c.pardes_pdf_owned_text);
+ if (c.pardes_pdf_copy_selection(
+ document.handle,
+ page_number,
+ start,
+ end,
+ &text,
+ ) != c.PARDES_PDF_OK) return error.TextFailed;
+ defer c.pardes_pdf_drop_owned_text(document.handle, text.handle);
+ if (text.len == 0) return gpa.dupe(u8, "");
+ if (text.data == null) return error.BadText;
+ const bytes: [*]const u8 = @ptrCast(text.data);
+ return gpa.dupe(u8, bytes[0..text.len]);
+ }
+
+ fn checkedPage(document: *const Document, page: usize) !c_int {
+ if (page >= document.pages or page > std.math.maxInt(c_int))
+ return error.PageOutOfRange;
+ return @intCast(page);
+ }
+};
+
+fn validPoint(point: Point) bool {
+ return std.math.isFinite(point.x) and std.math.isFinite(point.y) and
+ point.x >= 0 and point.x <= 1 and point.y >= 0 and point.y <= 1;
+}
+
+fn validQuad(quad: Quad) bool {
+ return validPoint(quad.ul) and validPoint(quad.ur) and
+ validPoint(quad.ll) and validPoint(quad.lr);
+}
+
+fn expandRgb(
+ rgba: []u8,
+ rgb: []const u8,
+ width: usize,
+ height: usize,
+ stride: usize,
+) !void {
+ if (rgba.len != width * height * 4 or stride < width * 3 or
+ rgb.len < stride * height)
+ return error.BadPixmap;
+ for (0..height) |y| {
+ const src = rgb[y * stride ..][0 .. width * 3];
+ const dst = rgba[y * width * 4 ..][0 .. width * 4];
+ for (0..width) |x| {
+ dst[x * 4 + 0] = src[x * 3 + 0];
+ dst[x * 4 + 1] = src[x * 3 + 1];
+ dst[x * 4 + 2] = src[x * 3 + 2];
+ dst[x * 4 + 3] = 0xff;
+ }
+ }
+}
+
+fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 {
+ const stream = "q 1 0 0 rg 120 220 160 150 re f Q\n";
+ var bytes: std.ArrayList(u8) = .empty;
+ errdefer bytes.deinit(gpa);
+ var offsets: [5]usize = @splat(0);
+
+ try bytes.appendSlice(gpa, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
+ offsets[1] = bytes.items.len;
+ try bytes.appendSlice(gpa, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+ offsets[2] = bytes.items.len;
+ try bytes.appendSlice(gpa, "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
+ offsets[3] = bytes.items.len;
+ try bytes.appendSlice(gpa, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] " ++
+ "/CropBox [120 220 280 370] /Rotate 90 /Resources << >> " ++
+ "/Contents 4 0 R >>\nendobj\n");
+ offsets[4] = bytes.items.len;
+ try bytes.print(gpa, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream });
+
+ const xref = bytes.items.len;
+ try bytes.appendSlice(gpa, "xref\n0 5\n0000000000 65535 f \n");
+ for (offsets[1..]) |offset|
+ try bytes.print(gpa, "{d:0>10} 00000 n \n", .{offset});
+ try bytes.print(gpa, "trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
+ return bytes.toOwnedSlice(gpa);
+}
+
+test "RGB pixmap expansion respects row stride and writes opaque RGBA" {
+ const rgb = [_]u8{
+ 1, 2, 3, 4, 5, 6, 99, 99,
+ 7, 8, 9, 10, 11, 12, 88, 88,
+ };
+ var rgba: [16]u8 = undefined;
+ try expandRgb(&rgba, &rgb, 2, 2, 8);
+ try std.testing.expectEqualSlices(u8, &.{
+ 1, 2, 3, 255, 4, 5, 6, 255,
+ 7, 8, 9, 255, 10, 11, 12, 255,
+ }, &rgba);
+}
+
+test "MuPDF search returns normalized oriented quads and word selection text" {
+ var document = try Document.open("docs/design.pdf");
+ defer document.deinit();
+
+ const projected = try document.pageText(std.testing.allocator, 0);
+ defer std.testing.allocator.free(projected);
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(projected, "Pardes") != null);
+ // Exercise transactional cache replacement before searching page zero
+ // again; a failed ownership handoff here tends to surface as a double drop.
+ if (document.pages > 1) {
+ const other_page = try document.pageText(std.testing.allocator, 1);
+ std.testing.allocator.free(other_page);
+ }
+
+ var found = try document.search(std.testing.allocator, 0, "Pardes");
+ defer found.deinit(std.testing.allocator);
+ try std.testing.expect(found.hit_count > 0);
+ try std.testing.expect(found.quads.len > 0);
+ for (found.quads) |item| {
+ try std.testing.expect(item.hit < found.hit_count);
+ try std.testing.expect(validQuad(item.quad));
+ }
+
+ const first = found.quads[0].quad;
+ var selected = try document.select(std.testing.allocator, 0, first.ul, first.lr);
+ defer selected.deinit(std.testing.allocator);
+ try std.testing.expect(selected.quads.len > 0);
+ try std.testing.expect(validPoint(selected.start));
+ try std.testing.expect(validPoint(selected.end));
+
+ const text = try document.copySelection(std.testing.allocator, 0, selected.start, selected.end);
+ defer std.testing.allocator.free(text);
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(text, "Pardes") != null);
+}
+
+test "highlighted render changes pixels while plain render stays stable" {
+ var document = try Document.open("docs/design.pdf");
+ defer document.deinit();
+
+ const plain_before = try document.render(std.testing.allocator, 0);
+ defer std.testing.allocator.free(plain_before.rgba);
+ const whole_page = Quad{
+ .ul = .{ .x = 0, .y = 0 },
+ .ur = .{ .x = 1, .y = 0 },
+ .ll = .{ .x = 0, .y = 1 },
+ .lr = .{ .x = 1, .y = 1 },
+ };
+ const highlights = [_]Highlight{
+ Highlight.init(whole_page, .{ 255, 0, 0, 128 }, .custom),
+ };
+ const marked = try document.renderWithHighlights(std.testing.allocator, 0, &highlights);
+ defer std.testing.allocator.free(marked.rgba);
+ const plain_after = try document.render(std.testing.allocator, 0);
+ defer std.testing.allocator.free(plain_after.rgba);
+
+ try std.testing.expectEqual(plain_before.width, marked.width);
+ try std.testing.expectEqual(plain_before.height, marked.height);
+ try std.testing.expect(!std.mem.eql(u8, plain_before.rgba, marked.rgba));
+ try std.testing.expectEqualSlices(u8, plain_before.rgba, plain_after.rgba);
+}
+
+test "offset crop and rotation keep normalized highlights pixel-aligned" {
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try makeOffsetRotatedPdf(std.testing.allocator);
+ defer std.testing.allocator.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{
+ .sub_path = "offset-rotated.pdf",
+ .data = fixture,
+ });
+ var path_buffer: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(
+ &path_buffer,
+ ".zig-cache/tmp/{s}/offset-rotated.pdf",
+ .{tmp.sub_path},
+ );
+
+ var document = try Document.open(path);
+ defer document.deinit();
+ const plain = try document.render(std.testing.allocator, 0);
+ defer std.testing.allocator.free(plain.rgba);
+ // CropBox is 160x150 points and /Rotate 90 swaps its displayed axes.
+ try std.testing.expectEqual(@as(usize, 300), plain.width);
+ try std.testing.expectEqual(@as(usize, 320), plain.height);
+
+ const left_half = Quad{
+ .ul = .{ .x = 0, .y = 0 },
+ .ur = .{ .x = 0.5, .y = 0 },
+ .ll = .{ .x = 0, .y = 1 },
+ .lr = .{ .x = 0.5, .y = 1 },
+ };
+ const highlights = [_]Highlight{
+ Highlight.init(left_half, .{ 0, 0, 255, 255 }, .custom),
+ };
+ const marked = try document.renderWithHighlights(std.testing.allocator, 0, &highlights);
+ defer std.testing.allocator.free(marked.rgba);
+ try std.testing.expectEqual(plain.width, marked.width);
+ try std.testing.expectEqual(plain.height, marked.height);
+
+ const left = (plain.height / 2 * plain.width + plain.width / 4) * 4;
+ const right = (plain.height / 2 * plain.width + plain.width * 3 / 4) * 4;
+ try std.testing.expect(plain.rgba[left] > 240 and
+ plain.rgba[left + 1] < 10 and plain.rgba[left + 2] < 10);
+ try std.testing.expect(marked.rgba[left] < 10 and
+ marked.rgba[left + 1] < 10 and marked.rgba[left + 2] > 240);
+ try std.testing.expectEqualSlices(u8, plain.rgba[right .. right + 4], marked.rgba[right .. right + 4]);
+}
diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c
new file mode 100644
index 00000000..a26fea55
--- /dev/null
+++ b/src/pdf_bridge.c
@@ -0,0 +1,686 @@
+#include "pdf_bridge.h"
+
+#include <mupdf/fitz.h>
+
+#include <math.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define PARDES_PDF_INITIAL_RESULT_QUADS 64
+
+struct pardes_pdf_document {
+ fz_context *ctx;
+ fz_document *doc;
+ int page_count;
+
+ /* One UI thread owns a document. Page switches invalidate this pair. */
+ int cached_page_number;
+ fz_page *cached_page;
+ fz_stext_page *cached_text;
+ fz_rect cached_bounds;
+};
+
+static void
+pardes_pdf_drop_cached_page(pardes_pdf_document *document)
+{
+ fz_drop_stext_page(document->ctx, document->cached_text);
+ fz_drop_page(document->ctx, document->cached_page);
+ document->cached_text = NULL;
+ document->cached_page = NULL;
+ document->cached_page_number = -1;
+}
+
+static int
+pardes_pdf_valid_bounds(fz_rect bounds)
+{
+ return isfinite(bounds.x0) && isfinite(bounds.y0) &&
+ isfinite(bounds.x1) && isfinite(bounds.y1) &&
+ bounds.x1 > bounds.x0 && bounds.y1 > bounds.y0;
+}
+
+/*
+ * Load into temporaries first, then replace the cache in one non-throwing
+ * commit. Thus a failed page/text load leaves the previous cached page valid.
+ */
+static void
+pardes_pdf_cache_page(
+ pardes_pdf_document *document,
+ int page_number,
+ int require_text)
+{
+ fz_context *ctx = document->ctx;
+ fz_page *page = NULL;
+ fz_stext_page *text = NULL;
+ fz_rect bounds;
+ fz_stext_options options = {0};
+ options.flags = FZ_STEXT_DEHYPHENATE;
+
+ if (document->cached_page_number == page_number) {
+ if (require_text && document->cached_text == NULL)
+ document->cached_text = fz_new_stext_page_from_page(
+ ctx, document->cached_page, &options);
+ return;
+ }
+
+ fz_var(page);
+ fz_var(text);
+ fz_try(ctx)
+ {
+ page = fz_load_page(ctx, document->doc, page_number);
+ bounds = fz_bound_page(ctx, page);
+ if (!pardes_pdf_valid_bounds(bounds))
+ fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds");
+ if (require_text)
+ text = fz_new_stext_page_from_page(ctx, page, &options);
+ }
+ fz_catch(ctx)
+ {
+ fz_drop_stext_page(ctx, text);
+ fz_drop_page(ctx, page);
+ fz_rethrow(ctx);
+ }
+
+ pardes_pdf_drop_cached_page(document);
+ document->cached_page_number = page_number;
+ document->cached_page = page;
+ document->cached_text = text;
+ document->cached_bounds = bounds;
+}
+
+static float
+pardes_pdf_clamp_unit(float value)
+{
+ if (value < 0.0f)
+ return 0.0f;
+ if (value > 1.0f)
+ return 1.0f;
+ return value;
+}
+
+static int
+pardes_pdf_valid_point(pardes_pdf_point point)
+{
+ return isfinite(point.x) && isfinite(point.y);
+}
+
+static fz_point
+pardes_pdf_point_to_page(pardes_pdf_document *document, pardes_pdf_point point)
+{
+ fz_rect bounds = document->cached_bounds;
+ float x = pardes_pdf_clamp_unit(point.x);
+ float y = pardes_pdf_clamp_unit(point.y);
+ return fz_make_point(
+ bounds.x0 + x * (bounds.x1 - bounds.x0),
+ bounds.y0 + y * (bounds.y1 - bounds.y0));
+}
+
+static pardes_pdf_point
+pardes_pdf_point_from_page(pardes_pdf_document *document, fz_point point)
+{
+ fz_rect bounds = document->cached_bounds;
+ pardes_pdf_point result;
+ result.x = pardes_pdf_clamp_unit(
+ (point.x - bounds.x0) / (bounds.x1 - bounds.x0));
+ result.y = pardes_pdf_clamp_unit(
+ (point.y - bounds.y0) / (bounds.y1 - bounds.y0));
+ return result;
+}
+
+static pardes_pdf_quad
+pardes_pdf_quad_from_page(pardes_pdf_document *document, fz_quad quad)
+{
+ pardes_pdf_quad result;
+ result.ul = pardes_pdf_point_from_page(document, quad.ul);
+ result.ur = pardes_pdf_point_from_page(document, quad.ur);
+ result.ll = pardes_pdf_point_from_page(document, quad.ll);
+ result.lr = pardes_pdf_point_from_page(document, quad.lr);
+ return result;
+}
+
+static int
+pardes_pdf_valid_quad(pardes_pdf_quad quad)
+{
+ return pardes_pdf_valid_point(quad.ul) &&
+ pardes_pdf_valid_point(quad.ur) &&
+ pardes_pdf_valid_point(quad.ll) &&
+ pardes_pdf_valid_point(quad.lr);
+}
+
+static fz_quad
+pardes_pdf_quad_to_page(pardes_pdf_document *document, pardes_pdf_quad quad)
+{
+ fz_quad result;
+ result.ul = pardes_pdf_point_to_page(document, quad.ul);
+ result.ur = pardes_pdf_point_to_page(document, quad.ur);
+ result.ll = pardes_pdf_point_to_page(document, quad.ll);
+ result.lr = pardes_pdf_point_to_page(document, quad.lr);
+ return result;
+}
+
+static size_t
+pardes_pdf_grown_capacity(size_t current, size_t needed, size_t maximum)
+{
+ size_t capacity = current == 0 ? PARDES_PDF_INITIAL_RESULT_QUADS : current;
+ while (capacity < needed) {
+ if (capacity > maximum / 2) {
+ capacity = maximum;
+ break;
+ }
+ capacity *= 2;
+ }
+ return capacity;
+}
+
+pardes_pdf_document *
+pardes_pdf_open(const char *path, int *page_count)
+{
+ pardes_pdf_document *state;
+ fz_context *ctx;
+ fz_document *doc = NULL;
+ int pages = 0;
+
+ if (path == NULL || page_count == NULL)
+ return NULL;
+
+ state = (pardes_pdf_document *)calloc(1, sizeof(*state));
+ if (state == NULL)
+ return NULL;
+ state->cached_page_number = -1;
+
+ ctx = fz_new_context(NULL, NULL, PARDES_PDF_STORE_LIMIT_BYTES);
+ if (ctx == NULL) {
+ free(state);
+ return NULL;
+ }
+
+ fz_var(doc);
+ fz_try(ctx)
+ {
+ fz_register_document_handlers(ctx);
+ doc = fz_open_document(ctx, path);
+ pages = fz_count_pages(ctx, doc);
+ if (pages < 1)
+ fz_throw(ctx, FZ_ERROR_FORMAT, "PDF has no pages");
+ }
+ fz_catch(ctx)
+ {
+ fz_report_error(ctx);
+ fz_drop_document(ctx, doc);
+ fz_drop_context(ctx);
+ free(state);
+ return NULL;
+ }
+
+ state->ctx = ctx;
+ state->doc = doc;
+ state->page_count = pages;
+ *page_count = pages;
+ return state;
+}
+
+void
+pardes_pdf_close(pardes_pdf_document *document)
+{
+ if (document == NULL)
+ return;
+ pardes_pdf_drop_cached_page(document);
+ fz_drop_document(document->ctx, document->doc);
+ fz_drop_context(document->ctx);
+ free(document);
+}
+
+int
+pardes_pdf_render(
+ pardes_pdf_document *document,
+ int page_number,
+ int max_dimension,
+ pardes_pdf_pixmap *out)
+{
+ return pardes_pdf_render_with_highlights(
+ document, page_number, max_dimension, NULL, 0, out);
+}
+
+int
+pardes_pdf_render_with_highlights(
+ pardes_pdf_document *document,
+ int page_number,
+ int max_dimension,
+ const pardes_pdf_highlight *highlights,
+ size_t highlight_count,
+ pardes_pdf_pixmap *out)
+{
+ fz_context *ctx;
+ fz_pixmap *pixmap = NULL;
+ fz_device *device = NULL;
+ fz_path *path = NULL;
+ fz_matrix ctm;
+ fz_rect bounds;
+ float longest;
+ float scale = 2.0f; /* 144 dpi; PDF user space is 72 dpi. */
+ size_t i;
+
+ if (document == NULL || out == NULL || page_number < 0 ||
+ page_number >= document->page_count || max_dimension < 1 ||
+ (highlight_count != 0 && highlights == NULL) ||
+ highlight_count > PARDES_PDF_MAX_RESULT_QUADS)
+ return PARDES_PDF_ERROR;
+
+ memset(out, 0, sizeof(*out));
+ ctx = document->ctx;
+ fz_var(pixmap);
+ fz_var(device);
+ fz_var(path);
+ fz_try(ctx)
+ {
+ pardes_pdf_cache_page(document, page_number, 0);
+ bounds = document->cached_bounds;
+ longest = fmaxf(bounds.x1 - bounds.x0, bounds.y1 - bounds.y0);
+ if (!(longest > 0.0f))
+ fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds");
+ if (longest * scale > (float)max_dimension)
+ scale = (float)max_dimension / longest;
+ /*
+ * Unlike a bare scale, fz_transform_page translates non-zero crop
+ * boxes to the pixmap origin and rounds both dimensions coherently.
+ * The overlay draw device receives this exact CTM, so normalized page
+ * geometry and page pixels stay aligned for offset and rotated pages.
+ */
+ ctm = fz_transform_page(bounds, scale * 72.0f, 0.0f);
+
+ /*
+ * alpha=0 is intentional. MuPDF clears this RGB pixmap to opaque
+ * white before drawing. Asking for alpha=1 instead produces
+ * premultiplied transparent samples, while Pardes consumes straight
+ * RGBA after the Zig-side RGB expansion.
+ */
+ pixmap = fz_new_pixmap_from_page(
+ ctx, document->cached_page, ctm, fz_device_rgb(ctx), 0);
+ if (fz_pixmap_width(ctx, pixmap) < 1 ||
+ fz_pixmap_height(ctx, pixmap) < 1)
+ fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page rendered empty");
+
+ if (highlight_count != 0) {
+ device = fz_new_draw_device(ctx, ctm, pixmap);
+ for (i = 0; i < highlight_count; ++i) {
+ fz_quad quad;
+ float color[3];
+ float alpha;
+
+ if (!pardes_pdf_valid_quad(highlights[i].quad))
+ fz_throw(ctx, FZ_ERROR_ARGUMENT,
+ "PDF highlight contains a non-finite point");
+ if (highlights[i].rgba[3] == 0)
+ continue;
+
+ /* Kind is semantic metadata; caller-supplied RGBA is final. */
+ (void)highlights[i].kind;
+ quad = pardes_pdf_quad_to_page(document, highlights[i].quad);
+ color[0] = highlights[i].rgba[0] / 255.0f;
+ color[1] = highlights[i].rgba[1] / 255.0f;
+ color[2] = highlights[i].rgba[2] / 255.0f;
+ alpha = highlights[i].rgba[3] / 255.0f;
+
+ path = fz_new_path(ctx);
+ fz_moveto(ctx, path, quad.ul.x, quad.ul.y);
+ fz_lineto(ctx, path, quad.ur.x, quad.ur.y);
+ fz_lineto(ctx, path, quad.lr.x, quad.lr.y);
+ fz_lineto(ctx, path, quad.ll.x, quad.ll.y);
+ fz_closepath(ctx, path);
+ fz_fill_path(ctx, device, path, 0, fz_identity,
+ fz_device_rgb(ctx), color, alpha,
+ fz_default_color_params);
+ fz_drop_path(ctx, path);
+ path = NULL;
+ }
+ fz_close_device(ctx, device);
+ }
+ }
+ fz_always(ctx)
+ {
+ fz_drop_path(ctx, path);
+ fz_drop_device(ctx, device);
+ }
+ fz_catch(ctx)
+ {
+ fz_report_error(ctx);
+ fz_drop_pixmap(ctx, pixmap);
+ return PARDES_PDF_ERROR;
+ }
+
+ out->handle = pixmap;
+ out->samples = fz_pixmap_samples(ctx, pixmap);
+ out->width = fz_pixmap_width(ctx, pixmap);
+ out->height = fz_pixmap_height(ctx, pixmap);
+ out->stride = fz_pixmap_stride(ctx, pixmap);
+ out->components = fz_pixmap_components(ctx, pixmap);
+ return PARDES_PDF_OK;
+}
+
+void
+pardes_pdf_drop_pixmap(pardes_pdf_document *document, void *pixmap)
+{
+ if (document != NULL && pixmap != NULL)
+ fz_drop_pixmap(document->ctx, (fz_pixmap *)pixmap);
+}
+
+int
+pardes_pdf_page_text(
+ pardes_pdf_document *document,
+ int page_number,
+ pardes_pdf_text *out)
+{
+ fz_context *ctx;
+ fz_buffer *buffer = NULL;
+ unsigned char *data = NULL;
+ size_t len = 0;
+
+ 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_var(buffer);
+ fz_try(ctx)
+ {
+ pardes_pdf_cache_page(document, page_number, 1);
+ buffer = fz_new_buffer_from_stext_page(ctx, document->cached_text);
+ len = fz_buffer_storage(ctx, buffer, &data);
+ }
+ fz_catch(ctx)
+ {
+ fz_report_error(ctx);
+ fz_drop_buffer(ctx, buffer);
+ return PARDES_PDF_ERROR;
+ }
+
+ out->handle = buffer;
+ out->data = data;
+ out->len = len;
+ return PARDES_PDF_OK;
+}
+
+void
+pardes_pdf_drop_text(pardes_pdf_document *document, void *buffer)
+{
+ if (document != NULL && buffer != NULL)
+ fz_drop_buffer(document->ctx, (fz_buffer *)buffer);
+}
+
+int
+pardes_pdf_search_page(
+ pardes_pdf_document *document,
+ int page_number,
+ const char *needle,
+ pardes_pdf_search_result *out)
+{
+ fz_context *ctx;
+ fz_search *search = NULL;
+ pardes_pdf_search_quad *items = NULL;
+ size_t capacity = 0;
+ size_t quad_count = 0;
+ size_t hit_count = 0;
+ int status = PARDES_PDF_ERROR;
+
+ if (document == NULL || needle == NULL || out == NULL || page_number < 0 ||
+ page_number >= document->page_count)
+ return PARDES_PDF_ERROR;
+
+ memset(out, 0, sizeof(*out));
+ ctx = document->ctx;
+ fz_var(search);
+ fz_var(items);
+ fz_var(status);
+ fz_try(ctx)
+ {
+ pardes_pdf_cache_page(document, page_number, 1);
+ search = fz_new_search(ctx);
+ fz_search_set_options(
+ ctx, search, FZ_SEARCH_IGNORE_CASE, needle);
+
+ /* fz_feed_search takes ownership; retain the document's cache ref. */
+ fz_feed_search(ctx, search,
+ fz_keep_stext_page(ctx, document->cached_text), page_number);
+
+ for (;;) {
+ fz_search_result result = fz_search_forwards(ctx, search);
+ if (result.reason == FZ_SEARCH_COMPLETE)
+ break;
+ if (result.reason == FZ_SEARCH_MORE_INPUT) {
+ /* This is deliberately a one-page search. */
+ fz_feed_search(ctx, search, NULL,
+ result.u.more_input.seq_needed);
+ continue;
+ }
+ if (result.reason == FZ_SEARCH_MATCH) {
+ fz_search_result_details *details = result.u.match.result;
+ size_t match_quads;
+ size_t needed;
+ size_t next_capacity;
+ size_t i;
+
+ if (details == NULL || details->num_quads < 0 ||
+ (details->num_quads > 0 && details->quads == NULL))
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "MuPDF returned malformed search geometry");
+ if (hit_count >= PARDES_PDF_MAX_RESULT_QUADS) {
+ status = PARDES_PDF_LIMIT_EXCEEDED;
+ fz_throw(ctx, FZ_ERROR_LIMIT,
+ "PDF search result limit exceeded");
+ }
+
+ match_quads = (size_t)details->num_quads;
+ if (match_quads > PARDES_PDF_MAX_RESULT_QUADS - quad_count) {
+ status = PARDES_PDF_LIMIT_EXCEEDED;
+ fz_throw(ctx, FZ_ERROR_LIMIT,
+ "PDF search quad limit exceeded");
+ }
+ needed = quad_count + match_quads;
+ if (needed > capacity) {
+ next_capacity = pardes_pdf_grown_capacity(
+ capacity, needed, PARDES_PDF_MAX_RESULT_QUADS);
+ items = fz_realloc_array(
+ ctx, items, next_capacity, pardes_pdf_search_quad);
+ capacity = next_capacity;
+ }
+ for (i = 0; i < match_quads; ++i) {
+ items[quad_count + i].quad = pardes_pdf_quad_from_page(
+ document, details->quads[i].quad);
+ items[quad_count + i].hit = hit_count;
+ }
+ quad_count = needed;
+ ++hit_count;
+ continue;
+ }
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "MuPDF returned an unknown search state");
+ }
+ status = PARDES_PDF_OK;
+ }
+ fz_always(ctx)
+ {
+ fz_drop_search(ctx, search);
+ }
+ fz_catch(ctx)
+ {
+ if (status != PARDES_PDF_LIMIT_EXCEEDED)
+ fz_report_error(ctx);
+ fz_free(ctx, items);
+ return status;
+ }
+
+ out->handle = items;
+ out->quads = items;
+ out->quad_count = quad_count;
+ out->hit_count = hit_count;
+ return PARDES_PDF_OK;
+}
+
+void
+pardes_pdf_drop_search_result(pardes_pdf_document *document, void *result)
+{
+ if (document != NULL && result != NULL)
+ fz_free(document->ctx, result);
+}
+
+int
+pardes_pdf_select(
+ pardes_pdf_document *document,
+ int page_number,
+ pardes_pdf_point start,
+ pardes_pdf_point end,
+ pardes_pdf_selection *out)
+{
+ fz_context *ctx;
+ fz_quad *raw = NULL;
+ pardes_pdf_quad *items = NULL;
+ fz_point a;
+ fz_point b;
+ int capacity = PARDES_PDF_INITIAL_RESULT_QUADS;
+ int count = 0;
+ int status = PARDES_PDF_ERROR;
+ int i;
+
+ if (document == NULL || out == NULL || page_number < 0 ||
+ page_number >= document->page_count ||
+ !pardes_pdf_valid_point(start) || !pardes_pdf_valid_point(end))
+ return PARDES_PDF_ERROR;
+
+ memset(out, 0, sizeof(*out));
+ ctx = document->ctx;
+ fz_var(raw);
+ fz_var(items);
+ fz_var(status);
+ fz_try(ctx)
+ {
+ pardes_pdf_cache_page(document, page_number, 1);
+ a = pardes_pdf_point_to_page(document, start);
+ b = pardes_pdf_point_to_page(document, end);
+ (void)fz_snap_selection(
+ ctx, document->cached_text, &a, &b, FZ_SELECT_WORDS);
+
+ raw = fz_malloc_array(ctx, capacity, fz_quad);
+ for (;;) {
+ int next_capacity;
+ count = fz_highlight_selection(
+ ctx, document->cached_text, a, b, raw, capacity);
+ if (count < 0)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "MuPDF returned an invalid selection count");
+ if (count < capacity)
+ break;
+
+ /* One extra slot distinguishes exactly-at-limit from truncation. */
+ if (capacity >= PARDES_PDF_MAX_RESULT_QUADS + 1) {
+ status = PARDES_PDF_LIMIT_EXCEEDED;
+ fz_throw(ctx, FZ_ERROR_LIMIT,
+ "PDF selection quad limit exceeded");
+ }
+ next_capacity = capacity * 2;
+ if (next_capacity > PARDES_PDF_MAX_RESULT_QUADS + 1)
+ next_capacity = PARDES_PDF_MAX_RESULT_QUADS + 1;
+ raw = fz_realloc_array(ctx, raw, next_capacity, fz_quad);
+ capacity = next_capacity;
+ }
+ if (count > PARDES_PDF_MAX_RESULT_QUADS) {
+ status = PARDES_PDF_LIMIT_EXCEEDED;
+ fz_throw(ctx, FZ_ERROR_LIMIT,
+ "PDF selection quad limit exceeded");
+ }
+
+ if (count != 0)
+ items = fz_malloc_array(ctx, (size_t)count, pardes_pdf_quad);
+ for (i = 0; i < count; ++i)
+ items[i] = pardes_pdf_quad_from_page(document, raw[i]);
+ status = PARDES_PDF_OK;
+ }
+ fz_always(ctx)
+ {
+ fz_free(ctx, raw);
+ }
+ fz_catch(ctx)
+ {
+ if (status != PARDES_PDF_LIMIT_EXCEEDED)
+ fz_report_error(ctx);
+ fz_free(ctx, items);
+ return status;
+ }
+
+ out->handle = items;
+ out->quads = items;
+ out->quad_count = (size_t)count;
+ out->start = pardes_pdf_point_from_page(document, a);
+ out->end = pardes_pdf_point_from_page(document, b);
+ return PARDES_PDF_OK;
+}
+
+void
+pardes_pdf_drop_selection(pardes_pdf_document *document, void *selection)
+{
+ if (document != NULL && selection != NULL)
+ fz_free(document->ctx, selection);
+}
+
+int
+pardes_pdf_point_inside_quad(pardes_pdf_point point, pardes_pdf_quad quad)
+{
+ fz_quad native;
+
+ if (!pardes_pdf_valid_point(point) || !pardes_pdf_valid_quad(quad))
+ return 0;
+ native.ul = fz_make_point(quad.ul.x, quad.ul.y);
+ native.ur = fz_make_point(quad.ur.x, quad.ur.y);
+ native.ll = fz_make_point(quad.ll.x, quad.ll.y);
+ native.lr = fz_make_point(quad.lr.x, quad.lr.y);
+ return fz_is_point_inside_quad(
+ fz_make_point(point.x, point.y), native) != 0;
+}
+
+int
+pardes_pdf_copy_selection(
+ pardes_pdf_document *document,
+ int page_number,
+ pardes_pdf_point start,
+ pardes_pdf_point end,
+ pardes_pdf_owned_text *out)
+{
+ fz_context *ctx;
+ char *text = NULL;
+ fz_point a;
+ fz_point b;
+
+ if (document == NULL || out == NULL || page_number < 0 ||
+ page_number >= document->page_count ||
+ !pardes_pdf_valid_point(start) || !pardes_pdf_valid_point(end))
+ return PARDES_PDF_ERROR;
+
+ memset(out, 0, sizeof(*out));
+ ctx = document->ctx;
+ fz_var(text);
+ fz_try(ctx)
+ {
+ pardes_pdf_cache_page(document, page_number, 1);
+ a = pardes_pdf_point_to_page(document, start);
+ b = pardes_pdf_point_to_page(document, end);
+ text = fz_copy_selection(ctx, document->cached_text, a, b, 0);
+ }
+ fz_catch(ctx)
+ {
+ fz_report_error(ctx);
+ fz_free(ctx, text);
+ return PARDES_PDF_ERROR;
+ }
+
+ out->handle = text;
+ out->data = (const unsigned char *)text;
+ out->len = text == NULL ? 0 : strlen(text);
+ return PARDES_PDF_OK;
+}
+
+void
+pardes_pdf_drop_owned_text(pardes_pdf_document *document, void *text)
+{
+ if (document != NULL && text != NULL)
+ fz_free(document->ctx, text);
+}
diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h
new file mode 100644
index 00000000..85f477d0
--- /dev/null
+++ b/src/pdf_bridge.h
@@ -0,0 +1,171 @@
+#ifndef PARDES_PDF_BRIDGE_H
+#define PARDES_PDF_BRIDGE_H
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/*
+ * MuPDF reports failures with setjmp/longjmp through fz_try/fz_catch. Those
+ * jumps must never cross Zig frames, so every operation that can throw lives
+ * behind this deliberately small C boundary.
+ */
+typedef struct pardes_pdf_document pardes_pdf_document;
+
+typedef struct pardes_pdf_pixmap {
+ void *handle;
+ const unsigned char *samples;
+ int width;
+ int height;
+ int stride;
+ int components;
+} pardes_pdf_pixmap;
+
+typedef struct pardes_pdf_text {
+ void *handle;
+ const unsigned char *data;
+ size_t len;
+} pardes_pdf_text;
+
+/*
+ * All geometry crossing this boundary is normalized to the cached page's
+ * bounds. (0,0) is the page's top-left and (1,1) its bottom-right, regardless
+ * of the DPI used for a particular render. Corner names retain MuPDF's quad
+ * orientation; callers must not collapse them to an axis-aligned rectangle.
+ */
+typedef struct pardes_pdf_point {
+ float x;
+ float y;
+} pardes_pdf_point;
+
+typedef struct pardes_pdf_quad {
+ pardes_pdf_point ul;
+ pardes_pdf_point ur;
+ pardes_pdf_point ll;
+ pardes_pdf_point lr;
+} pardes_pdf_quad;
+
+typedef enum pardes_pdf_highlight_kind {
+ PARDES_PDF_HIGHLIGHT_CUSTOM = 0,
+ PARDES_PDF_HIGHLIGHT_SEARCH = 1,
+ PARDES_PDF_HIGHLIGHT_SELECTION = 2
+} pardes_pdf_highlight_kind;
+
+typedef struct pardes_pdf_highlight {
+ pardes_pdf_quad quad;
+ unsigned char rgba[4];
+ int kind;
+} pardes_pdf_highlight;
+
+typedef struct pardes_pdf_search_quad {
+ pardes_pdf_quad quad;
+ size_t hit;
+} pardes_pdf_search_quad;
+
+typedef struct pardes_pdf_search_result {
+ void *handle;
+ const pardes_pdf_search_quad *quads;
+ size_t quad_count;
+ size_t hit_count;
+} pardes_pdf_search_result;
+
+typedef struct pardes_pdf_selection {
+ void *handle;
+ const pardes_pdf_quad *quads;
+ size_t quad_count;
+ pardes_pdf_point start;
+ pardes_pdf_point end;
+} pardes_pdf_selection;
+
+typedef struct pardes_pdf_owned_text {
+ void *handle;
+ const unsigned char *data;
+ size_t len;
+} pardes_pdf_owned_text;
+
+enum {
+ PARDES_PDF_OK = 0,
+ PARDES_PDF_ERROR = -1,
+ PARDES_PDF_LIMIT_EXCEEDED = -2,
+
+ /* Per-document MuPDF eviction cache; render buffers are separate. */
+ PARDES_PDF_STORE_LIMIT_BYTES = 64 * 1024 * 1024,
+
+ /*
+ * Search grows geometrically up to this aggregate count and then fails
+ * atomically with PARDES_PDF_LIMIT_EXCEEDED; it never returns a silently
+ * truncated result. Selection uses one extra private probe slot so an
+ * exact-at-the-limit result remains distinguishable from truncation.
+ */
+ PARDES_PDF_MAX_RESULT_QUADS = 65536
+};
+
+pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count);
+void pardes_pdf_close(pardes_pdf_document *document);
+
+int pardes_pdf_render(
+ pardes_pdf_document *document,
+ int page,
+ int max_dimension,
+ pardes_pdf_pixmap *out
+);
+
+int pardes_pdf_render_with_highlights(
+ pardes_pdf_document *document,
+ int page,
+ int max_dimension,
+ const pardes_pdf_highlight *highlights,
+ size_t highlight_count,
+ pardes_pdf_pixmap *out
+);
+void pardes_pdf_drop_pixmap(pardes_pdf_document *document, void *pixmap);
+
+int pardes_pdf_page_text(
+ pardes_pdf_document *document,
+ int page,
+ pardes_pdf_text *out
+);
+void pardes_pdf_drop_text(pardes_pdf_document *document, void *buffer);
+
+/* Case-insensitive, single-page search using MuPDF 1.27's iterative API. */
+int pardes_pdf_search_page(
+ pardes_pdf_document *document,
+ int page,
+ const char *needle,
+ pardes_pdf_search_result *out
+);
+void pardes_pdf_drop_search_result(pardes_pdf_document *document, void *result);
+
+/* Word-snap two normalized points and return oriented highlight quads. */
+int pardes_pdf_select(
+ pardes_pdf_document *document,
+ int page,
+ pardes_pdf_point start,
+ pardes_pdf_point end,
+ pardes_pdf_selection *out
+);
+void pardes_pdf_drop_selection(pardes_pdf_document *document, void *selection);
+
+/* Pure/no-throw oriented-quad hit test in normalized page space. */
+int pardes_pdf_point_inside_quad(
+ pardes_pdf_point point,
+ pardes_pdf_quad quad
+);
+
+/* The input endpoints are normally the snapped points returned above. */
+int pardes_pdf_copy_selection(
+ pardes_pdf_document *document,
+ int page,
+ pardes_pdf_point start,
+ pardes_pdf_point end,
+ pardes_pdf_owned_text *out
+);
+void pardes_pdf_drop_owned_text(pardes_pdf_document *document, void *text);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index f0013020..60a2d958 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -91,6 +91,90 @@ const Pty = struct {
reader: std.Io.Future(anyerror!void),
};
+const KittyPlacement = struct {
+ cell_x: u16,
+ cell_y: u16,
+ options: vaxis.Image.DrawOptions,
+};
+
+fn terminalCellPixels(total_pixels: u16, cells: u16, fallback: u32) u32 {
+ if (total_pixels == 0 or cells == 0) return fallback;
+ return @max(1, @as(u32, total_pixels) / cells);
+}
+
+fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u16, pixel_h: u16) void {
+ core.update(.{ .resize = .{
+ .cols = cols,
+ .rows = rows,
+ .cell_pixels = .{
+ .w = @intCast(terminalCellPixels(pixel_w, cols, 8)),
+ .h = @intCast(terminalCellPixels(pixel_h, rows, 16)),
+ },
+ } });
+}
+
+/// Translate backend-neutral source/destination pixels into Kitty's source
+/// crop plus cell-sized placement. Kitty can specify only one scaled axis
+/// without distorting the image; the terminal derives the other axis.
+fn kittyPlacement(
+ place: pardes.ImagePlace,
+ screen_cols: u16,
+ screen_rows: u16,
+ screen_pixel_w: u16,
+ screen_pixel_h: u16,
+) ?KittyPlacement {
+ if (comptime pardes.pdf_enabled) {
+ if (place.native.fit == .contain) return null;
+ const cell_w = terminalCellPixels(screen_pixel_w, screen_cols, 8);
+ 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(
+ place.iw,
+ place.ih,
+ body_w,
+ body_h,
+ place.native.fit,
+ place.native.pan_x,
+ 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.
+ 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 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;
+
+ return .{
+ .cell_x = cell_x,
+ .cell_y = cell_y,
+ .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 },
+ .contain => unreachable,
+ },
+ },
+ };
+ } else {
+ return 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);
+}
+
pub fn run(init: std.process.Init, opts: pardes.Options) !void {
const io = init.io;
const gpa = init.gpa;
@@ -143,7 +227,7 @@ 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 { serial: u32, image: vaxis.Image };
+ 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);
@@ -279,7 +363,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.quit => stop = true,
.winsize => |ws| {
try vx.resize(gpa, tty.writer(), ws);
- core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } });
+ if (comptime pardes.pdf_enabled)
+ updateCoreTerminalSize(core, ws.cols, ws.rows, ws.x_pixel, ws.y_pixel)
+ else
+ core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } });
},
.pty_read => |pr| {
core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
@@ -404,6 +491,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
nc.native_images = vx.caps.kitty_graphics;
core.deinit();
core = nc;
+ if (comptime pardes.pdf_enabled) {
+ // A restored core did not receive the terminal's earlier
+ // winsize event. Reapply both the grid and physical cells
+ // before its first PDF frame so pointer/pan geometry stays
+ // identical to placement.
+ updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix);
+ }
}
// live cwd for tags/look: cheap per-pane lookup, per frame
@@ -451,15 +545,19 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
}
}
tz_cells.end();
- // Pixel attachments (kitty graphics): transmit once per pane
+ // 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,
- // so the pane number by itself is not a cache key.
+ // 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);
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.serial != place.serial) {
+ if (kitty_handles[place.pane]) |cached| if (!cached.key.eql(place.cacheKey())) {
vx.freeImage(tty.writer(), cached.image.id);
kitty_handles[place.pane] = null;
};
@@ -469,12 +567,33 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
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] = .{ .serial = place.serial, .image = handle };
+ kitty_handles[place.pane] = .{ .key = place.cacheKey(), .image = handle };
} else |_| {}
}
if (kitty_handles[place.pane]) |cached| {
- 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 {};
+ 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 {};
+ } 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 {};
+ } else if (kittyPlacement(
+ place,
+ vx.screen.width,
+ vx.screen.height,
+ vx.screen.width_pix,
+ vx.screen.height_pix,
+ )) |placement| {
+ 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,
+ });
+ cached.image.draw(child, placement.options) catch {};
+ }
+ }
}
}
// Toggling PETSCII or closing a pane removes its attachment from the