summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/tty/tty.zig41
-rw-r--r--test/image_harness.zig147
2 files changed, 164 insertions, 24 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 6f7cc5ba..343452b5 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -176,6 +176,31 @@ fn kittyImageRepresentable(place: pardes.ImagePlace) bool {
place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16);
}
+const PdfWheelTarget = struct { pane: usize, page: usize };
+
+/// A native PDF's page geometry is invalid between `setPdfPage` and the next
+/// render. Remember the page under a vertical wheel press so the input batch
+/// can stop exactly when that press crosses a page boundary. The following
+/// queued wheel report then sees the newly rastered page instead of treating
+/// missing geometry as another page-wise fallback.
+fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWheelTarget {
+ if (comptime !pardes.pdf_enabled) return null;
+ if (!core.native_images or mouse.type != .press or
+ (mouse.button != .wheel_up and mouse.button != .wheel_down) or
+ mouse.col < 0 or mouse.row < 0) return null;
+ const col: u16 = @intCast(mouse.col);
+ const row: u16 = @intCast(mouse.row);
+ for (core.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const rect = core.rects[id];
+ if (col < rect.x or col >= rect.x + rect.w or
+ row < rect.y or row >= rect.y + rect.h) continue;
+ const page = pane.pdfPage() orelse return null;
+ return .{ .pane = id, .page = page };
+ }
+ return null;
+}
+
pub fn run(init: std.process.Init, opts: pardes.Options) !void {
const io = init.io;
const gpa = init.gpa;
@@ -353,12 +378,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
const tz_event = tracy.zone(@src(), "event");
// Apply every queued INPUT event, then render ONCE — the gui shell
// drains SDL's queue the same way. Without this a wheel flick is fifty
- // full render+repaint (and re-highlight) cycles instead of one.
- // pty output keeps a frame per chunk: progress paints as it arrives.
+ // full render+repaint (and re-highlight) cycles instead of one. A
+ // native PDF page crossing is the one input boundary below: its next
+ // wheel report needs geometry produced by this render. Pty output also
+ // keeps a frame per chunk so progress paints as it arrives.
var stop = false;
var batch: usize = 0;
while (true) {
var output = false;
+ var native_pdf_page_changed = false;
switch (event) {
.nop, .tick => {},
.quit => stop = true,
@@ -390,6 +418,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.shift = key.mods.shift,
} }),
.mouse => |m| {
+ const pdf_before = nativePdfWheelTarget(core, m);
const button: ?pardes.Mouse.Button = switch (m.button) {
.left => .left,
.middle => .middle,
@@ -413,6 +442,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.row = @intCast(m.row),
.ctrl = m.mods.ctrl,
} });
+ if (pdf_before) |before| {
+ if (core.panes[before.pane]) |pane| {
+ if (pane.pdfPage()) |page|
+ native_pdf_page_changed = page != before.page;
+ }
+ }
},
.paste => |bytes| {
core.update(.{ .paste = bytes });
@@ -434,7 +469,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
},
}
batch += 1;
- if (stop or output or batch >= 64) break;
+ if (stop or output or native_pdf_page_changed or batch >= 64) break;
event = (try loop.tryEvent()) orelse break;
}
tz_event.end();
diff --git a/test/image_harness.zig b/test/image_harness.zig
index bd465e1c..f8cdf218 100644
--- a/test/image_harness.zig
+++ b/test/image_harness.zig
@@ -4,7 +4,7 @@
//! real transmit + placement APCs
//! zig build image-harness -Dplatform=gui run the real SDL GPU renderer,
//! capture a PPM, inspect pixels
-//! zig build pdf-harness render a generated two-page PDF
+//! zig build pdf-harness render a generated multi-page PDF
//! zig build pdf-harness -Dplatform=gui
//!
//! Unlike the text snapshot suite, both arms observe the thing the native
@@ -74,10 +74,11 @@ fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: []
try out.appendSlice(arena, try std.fmt.allocPrint(arena, fmt, args));
}
-/// A self-contained, valid PDF fixture. Page one is solid red and contains
-/// FIRST NEEDLE; page two is solid green and contains SECOND TARGET. Building
-/// it here keeps the backend test independent of mutool, fonts, and checked-in
-/// binary fixtures while the xref offsets remain exact.
+/// A self-contained, valid PDF fixture. The first two pages retain the red /
+/// green and search-text contract exercised below; two differently-sized
+/// trailing pages make skipped native renders visible in Kitty's transmit
+/// headers. Building it here keeps the backend test independent of mutool,
+/// fonts, and checked-in binary fixtures while the xref offsets remain exact.
fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
const first_stream =
\\1 0 0 rg
@@ -101,31 +102,61 @@ fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
\\(SECOND TARGET) Tj
\\ET
;
+ const third_stream =
+ \\0 0 1 rg
+ \\0 0 72 50 re
+ \\f
+ \\0 0 0 rg
+ \\BT
+ \\/F1 10 Tf
+ \\4 20 Td
+ \\(THIRD PAGE) Tj
+ \\ET
+ ;
+ const fourth_stream =
+ \\1 1 0 rg
+ \\0 0 72 52 re
+ \\f
+ \\0 0 0 rg
+ \\BT
+ \\/F1 10 Tf
+ \\4 20 Td
+ \\(FOURTH PAGE) Tj
+ \\ET
+ ;
var out: std.ArrayList(u8) = .empty;
- var offsets: [8]usize = @splat(0);
+ var offsets: [12]usize = @splat(0);
try out.appendSlice(arena, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
offsets[1] = out.items.len;
try out.appendSlice(arena, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
offsets[2] = out.items.len;
- try out.appendSlice(arena, "2 0 obj\n<< /Type /Pages /Count 2 /Kids [3 0 R 5 0 R] >>\nendobj\n");
+ try out.appendSlice(arena, "2 0 obj\n<< /Type /Pages /Count 4 /Kids [3 0 R 5 0 R 7 0 R 9 0 R] >>\nendobj\n");
offsets[3] = out.items.len;
- try out.appendSlice(arena, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>\nendobj\n");
+ try out.appendSlice(arena, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 4 0 R >>\nendobj\n");
offsets[4] = out.items.len;
try appendFmt(arena, &out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream });
offsets[5] = out.items.len;
- try out.appendSlice(arena, "5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>\nendobj\n");
+ try out.appendSlice(arena, "5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 6 0 R >>\nendobj\n");
offsets[6] = out.items.len;
try appendFmt(arena, &out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream });
offsets[7] = out.items.len;
- try out.appendSlice(arena, "7 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
+ try out.appendSlice(arena, "7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 50] /Resources << /Font << /F1 11 0 R >> >> /Contents 8 0 R >>\nendobj\n");
+ offsets[8] = out.items.len;
+ try appendFmt(arena, &out, "8 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ third_stream.len, third_stream });
+ offsets[9] = out.items.len;
+ try out.appendSlice(arena, "9 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 52] /Resources << /Font << /F1 11 0 R >> >> /Contents 10 0 R >>\nendobj\n");
+ offsets[10] = out.items.len;
+ try appendFmt(arena, &out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream });
+ offsets[11] = out.items.len;
+ try out.appendSlice(arena, "11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
const xref = out.items.len;
- try out.appendSlice(arena, "xref\n0 8\n0000000000 65535 f \n");
+ try out.appendSlice(arena, "xref\n0 12\n0000000000 65535 f \n");
for (offsets[1..]) |offset|
try appendFmt(arena, &out, "{d:0>10} 00000 n \n", .{offset});
- try appendFmt(arena, &out, "trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
+ try appendFmt(arena, &out, "trailer\n<< /Size 12 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
try eh.writeFile(path, out.items);
}
@@ -197,11 +228,11 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
_ = try h.pumpOnce(50);
const sent = std.mem.indexOf(u8, h.raw.items, transmit) != null;
const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null;
- if (sent and placed and h.contains("pdf 1/2 width PdfFit")) break;
+ if (sent and placed and h.contains("pdf 1/4 width PdfFit")) break;
}
try h.expectRawContains(transmit, "first PDF page was not transmitted as 144x96 RGBA");
try h.expectRawContains("\x1b_Ga=p,i=", "first PDF page was not placed in the pane body");
- try h.expectContains("pdf 1/2 width PdfFit", "PDF pane did not expose its initial width fit");
+ try h.expectContains("pdf 1/4 width PdfFit", "PDF pane did not expose its initial width fit");
// Fit-width is the initial PDF policy. It must use Kitty's explicit column
// scaling rather than Vaxis contain. The PDF shares this test session with
@@ -245,7 +276,7 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
_ = try h.pumpOnce(50);
if (kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') != null) break;
}
- try h.expectContains("pdf 1/2 height PdfFit", "PdfFit did not expose the active height fit");
+ try h.expectContains("pdf 1/4 height PdfFit", "PdfFit did not expose the active height fit");
const height_apc = kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') orelse
return error.KittyPdfHeightPlacementMissing;
const height_crop_w = kittyParam(height_apc, 'w') orelse return error.KittyPdfHeightPlacementWrong;
@@ -263,18 +294,18 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
while (eh.nowMs() < second_deadline) {
_ = try h.pumpOnce(50);
const deleted = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") != null;
- if (deleted and std.mem.count(u8, h.raw.items, transmit) >= 2 and h.contains("pdf 2/2 height PdfFit")) break;
+ if (deleted and std.mem.count(u8, h.raw.items, transmit) >= 2 and h.contains("pdf 2/4 height PdfFit")) break;
}
try h.expectRawContains("\x1b_Ga=d,d=I,i=", "page navigation did not release the old Kitty PDF image");
if (std.mem.count(u8, h.raw.items, transmit) < 2) return error.KittyPdfRetransmitMissing;
- try h.expectContains("pdf 2/2 height PdfFit", "j did not retain height fit on PDF page two");
+ try h.expectContains("pdf 2/4 height PdfFit", "j did not retain height fit on PDF page two");
// Exercise the semantic seam too: return to page one, search an exact
// occurrence across the document, then let n Look the page-two result.
// That Look must change the page and therefore produce another pixel
// generation.
try h.send("k");
- try h.expectWaitFor("pdf 1/2 height PdfFit", 5000, "k did not return to PDF page one at height fit");
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "k did not return to PDF page one at height fit");
try h.send("/SECOND\r");
try h.expectWaitFor("pages.pdf:2:1 SECOND", 5000, "PDF search did not preserve the exact page-two query occurrence");
const before_look = std.mem.count(u8, h.raw.items, transmit);
@@ -282,11 +313,85 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
const look_deadline = eh.nowMs() + 5000;
while (eh.nowMs() < look_deadline) {
_ = try h.pumpOnce(50);
- if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/2 height PdfFit")) break;
+ if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/4 height PdfFit")) break;
}
- try h.expectContains("pdf 2/2 height PdfFit", "n did not Look the PDF search result at page two");
+ try h.expectContains("pdf 2/4 height PdfFit", "n did not Look the PDF search result at page two");
if (std.mem.count(u8, h.raw.items, transmit) <= before_look) return error.KittyPdfLookRetransmitMissing;
- std.debug.print("PDF harness ok: Kitty fit crop/cache + page revision + search Look\n", .{});
+
+ // Kitty turns a larger wheel offset into adjacent SGR wheel presses. The
+ // TTY shell may drain those presses in one input batch, but every native
+ // PDF page transition must be rastered before the next press consumes the
+ // new page's geometry. Distinct page heights make that ordering observable
+ // in the actual Kitty transmit headers: page 2 is 144x96, page 3 144x100,
+ // and page 4 144x104. This is also a direct one-detent check before the
+ // burst and a direction-reversal check after it.
+ const wheel_up = try std.fmt.allocPrint(arena, "\x1b[<64;{d};{d}M", .{ mouse_col + 1, mouse_row + 1 });
+ const wheel_down = try std.fmt.allocPrint(arena, "\x1b[<65;{d};{d}M", .{ mouse_col + 1, mouse_row + 1 });
+ const page_three_transmit = "\x1b_Gf=32,s=144,v=100,i=";
+ const page_four_transmit = "\x1b_Gf=32,s=144,v=104,i=";
+
+ try h.send(wheel_up);
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "wheel-up did not return to PDF page one");
+ const single_start = h.raw.items.len;
+ try h.send(wheel_down);
+ try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "one wheel detent did not advance exactly one PDF page");
+ try h.pump(100);
+ if (std.mem.indexOf(u8, h.raw.items[single_start..], page_three_transmit) != null)
+ return error.KittyPdfSingleWheelSkippedPage;
+
+ try h.send(wheel_up);
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "wheel-up did not reset the burst fixture");
+ const burst_start = h.raw.items.len;
+ const forward_burst = try std.fmt.allocPrint(
+ arena,
+ "\x1b[<65;{d};{d}M\x1b[<65;{d};{d}M\x1b[<65;{d};{d}M",
+ .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 },
+ );
+ try h.send(forward_burst);
+ try h.expectWaitFor("pdf 4/4 height PdfFit", 8000, "forward Kitty wheel burst did not reach PDF page four");
+ const forward_raw = h.raw.items[burst_start..];
+ const page_two_at = std.mem.indexOf(u8, forward_raw, transmit) orelse
+ return error.KittyPdfBurstSkippedPageTwoRender;
+ const page_three_at = std.mem.indexOf(u8, forward_raw, page_three_transmit) orelse
+ return error.KittyPdfBurstSkippedPageThreeRender;
+ const page_four_at = std.mem.indexOf(u8, forward_raw, page_four_transmit) orelse
+ return error.KittyPdfBurstSkippedPageFourRender;
+ if (!(page_two_at < page_three_at and page_three_at < page_four_at))
+ return error.KittyPdfBurstRenderOrderWrong;
+
+ const reverse_start = h.raw.items.len;
+ const reverse_burst = try std.fmt.allocPrint(
+ arena,
+ "\x1b[<64;{d};{d}M\x1b[<64;{d};{d}M\x1b[<65;{d};{d}M\x1b[<64;{d};{d}M",
+ .{
+ mouse_col + 1, mouse_row + 1,
+ mouse_col + 1, mouse_row + 1,
+ mouse_col + 1, mouse_row + 1,
+ mouse_col + 1, mouse_row + 1,
+ },
+ );
+ try h.send(reverse_burst);
+ try h.expectWaitFor("pdf 2/4 height PdfFit", 8000, "mixed-direction Kitty wheel burst did not return to PDF page two");
+ const reverse_deadline = eh.nowMs() + 5000;
+ while (eh.nowMs() < reverse_deadline) {
+ const emitted = h.raw.items[reverse_start..];
+ if (std.mem.count(u8, emitted, page_three_transmit) >= 2 and
+ std.mem.count(u8, emitted, transmit) >= 2) break;
+ _ = try h.pumpOnce(50);
+ }
+ const reverse_raw = h.raw.items[reverse_start..];
+ const reverse_three_1 = std.mem.indexOf(u8, reverse_raw, page_three_transmit) orelse
+ return error.KittyPdfReverseBurstSkippedPageThreeRender;
+ const reverse_two_1 = std.mem.indexOfPos(u8, reverse_raw, reverse_three_1 + page_three_transmit.len, transmit) orelse
+ return error.KittyPdfReverseBurstSkippedPageTwoRender;
+ const reverse_three_2 = std.mem.indexOfPos(u8, reverse_raw, reverse_two_1 + transmit.len, page_three_transmit) orelse
+ return error.KittyPdfDirectionChangeSkippedPageThreeRender;
+ const reverse_two_2 = std.mem.indexOfPos(u8, reverse_raw, reverse_three_2 + page_three_transmit.len, transmit) orelse
+ return error.KittyPdfDirectionChangeSkippedPageTwoRender;
+ if (!(reverse_three_1 < reverse_two_1 and reverse_two_1 < reverse_three_2 and reverse_three_2 < reverse_two_2))
+ return error.KittyPdfReverseBurstRenderOrderWrong;
+
+ std.debug.print("PDF harness ok: Kitty fit/cache + page/search + metered wheel transitions\n", .{});
}
fn kittyPlacementApc(raw: []const u8, start: usize) ?[]const u8 {