From 820ff337d156831d250ff0fcef5139fe69474e4e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 19:45:52 -0300 Subject: fix PDF mouse-wheel scrolling jumping multiple pages --- test/image_harness.zig | 147 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 126 insertions(+), 21 deletions(-) (limited to 'test') 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 { -- cgit v1.3