From 5961587b227e5fa097fb033e32c29da08a23fb18 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 17:17:35 -0300 Subject: tty/image: big harness + golden coverage pass --- src/tty/tty.zig | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 127 insertions(+), 8 deletions(-) (limited to 'src/tty') 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 -- cgit v1.3