diff options
Diffstat (limited to 'src/tty/tty.zig')
| -rw-r--r-- | src/tty/tty.zig | 196 |
1 files changed, 158 insertions, 38 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 343452b5..90477d06 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -95,6 +95,8 @@ const Pty = struct { const KittyPlacement = struct { cell_x: u16, cell_y: u16, + cell_cols: u16, + cell_rows: u16, options: vaxis.Image.DrawOptions, }; @@ -130,7 +132,7 @@ fn kittyPlacement( const cell_h = terminalCellPixels(screen_pixel_h, screen_rows, 16); const body_w = std.math.mul(u32, place.w, cell_w) catch return null; const body_h = std.math.mul(u32, place.h, cell_h) catch return null; - const geometry = pardes.image.nativeGeometry( + const geometry = place.native.geometry orelse pardes.image.nativeGeometry( place.iw, place.ih, body_w, @@ -140,28 +142,89 @@ fn kittyPlacement( place.native.pan_y, ) orelse return null; - // Every Kitty protocol crop field is u16. Reject an attachment which - // the wire format cannot represent instead of truncating it into - // another part of the page. + // Kitty has a top-left pixel offset but no destination bottom clip. + // Its missing c/r axis is rounded up to whole terminal cells, so a + // clipped fragment shorter than one row cannot be represented without + // painting the following theme gap. Conservatively keep only whole + // rows contained by geometry.dst and trim the source crop to the same + // scale. Cached page pixels remain unchanged; an unrepresentable tail + // is simply left as theme background. + const safe_rows_u32 = geometry.dst.h / cell_h; + if (safe_rows_u32 == 0) return null; + const safe_pixel_h = safe_rows_u32 * cell_h; + var safe_src_h: u32 = geometry.src.h; + var declared_cols: u32 = 0; + var declared_rows: u32 = 0; + switch (place.native.fit) { + .width => { + declared_cols = @intCast(@max( + @as(u64, 1), + (@as(u64, geometry.dst.w) + cell_w - 1) / cell_w, + )); + const declared_pixel_w = @as(u64, declared_cols) * cell_w; + const max_src_h: u32 = @intCast( + @as(u64, safe_pixel_h) * geometry.src.w / declared_pixel_w, + ); + if (max_src_h == 0) return null; + safe_src_h = @min(safe_src_h, max_src_h); + const aspect_pixel_h: u32 = @intCast( + (@as(u64, safe_src_h) * declared_pixel_w + geometry.src.w - 1) / + geometry.src.w, + ); + declared_rows = @intCast( + (@as(u64, aspect_pixel_h) + cell_h - 1) / cell_h, + ); + }, + .height => { + declared_rows = safe_rows_u32; + safe_src_h = @max(@as(u32, 1), @as(u32, @intCast( + @as(u64, safe_pixel_h) * geometry.src.h / geometry.dst.h, + ))); + safe_src_h = @min(safe_src_h, geometry.src.h); + const aspect_pixel_w: u32 = @intCast( + (@as(u64, geometry.src.w) * safe_pixel_h + safe_src_h - 1) / + safe_src_h, + ); + declared_cols = @intCast( + (@as(u64, aspect_pixel_w) + cell_w - 1) / cell_w, + ); + }, + .contain => unreachable, + } + if (declared_rows == 0 or declared_rows > safe_rows_u32) return null; + + // Every Kitty protocol field is u16. Reject an attachment which the + // wire format cannot represent instead of truncating it. const src_x = std.math.cast(u16, geometry.src.x) orelse return null; const src_y = std.math.cast(u16, geometry.src.y) orelse return null; const src_w = std.math.cast(u16, geometry.src.w) orelse return null; - const src_h = std.math.cast(u16, geometry.src.h) orelse return null; + const src_h = std.math.cast(u16, safe_src_h) orelse return null; const cell_x = std.math.cast(u16, geometry.dst.x / cell_w) orelse return null; const cell_y = std.math.cast(u16, geometry.dst.y / cell_h) orelse return null; if (cell_x >= place.w or cell_y >= place.h) return null; const pixel_x = std.math.cast(u16, geometry.dst.x % cell_w) orelse return null; const pixel_y = std.math.cast(u16, geometry.dst.y % cell_h) orelse return null; + const cell_cols = std.math.cast(u16, @min( + @as(u32, place.w - cell_x), + (@as(u64, pixel_x) + @as(u64, declared_cols) * cell_w + cell_w - 1) / cell_w, + )) orelse return null; + const cell_rows = std.math.cast(u16, @min( + @as(u32, place.h - cell_y), + (@as(u64, pixel_y) + declared_rows * cell_h + cell_h - 1) / cell_h, + )) orelse return null; + if (cell_cols == 0 or cell_rows == 0) return null; return .{ .cell_x = cell_x, .cell_y = cell_y, + .cell_cols = cell_cols, + .cell_rows = cell_rows, .options = .{ .clip_region = .{ .x = src_x, .y = src_y, .width = src_w, .height = src_h }, .pixel_offset = if (pixel_x != 0 or pixel_y != 0) .{ .x = pixel_x, .y = pixel_y } else null, .size = switch (place.native.fit) { - .width => .{ .cols = place.w }, - .height => .{ .rows = place.h }, + .width => .{ .cols = std.math.cast(u16, declared_cols) orelse return null }, + .height => .{ .rows = std.math.cast(u16, declared_rows) orelse return null }, .contain => unreachable, }, }, @@ -171,11 +234,70 @@ fn kittyPlacement( } } +test "Kitty PDF fragments never declare pixels beyond their clipped bottom" { + if (comptime !pardes.pdf_enabled) return; + + const base = pardes.ImagePlace{ + .pane = 0, + .serial = 1, + .native = .{ + .revision = 1, + .page = 0, + .fit = .width, + .geometry = .{ + .src = .{ .w = 96, .h = 10 }, + .dst = .{ .y = 195, .w = 304, .h = 29 }, + }, + }, + .x = 0, + .y = 0, + .w = 38, + .h = 14, + .rgba = &.{}, + .iw = 96, + .ih = 64, + }; + const width = kittyPlacement(base, 80, 16, 640, 256) orelse + return error.MissingSafeKittyWidthFragment; + const width_clip = width.options.clip_region.?; + const width_size = width.options.size.?; + try std.testing.expectEqual(@as(u16, 5), width_clip.height.?); + try std.testing.expectEqual(@as(u16, 38), width_size.cols.?); + try std.testing.expectEqual(@as(u16, 3), width.options.pixel_offset.?.y); + const aspect_pixels = (@as(u32, width_size.cols.?) * 8 * width_clip.height.? + + width_clip.width.? - 1) / width_clip.width.?; + const inferred_rows = (aspect_pixels + 15) / 16; + try std.testing.expect(inferred_rows * 16 <= base.native.geometry.?.dst.h); + + var height = base; + height.native.fit = .height; + height.native.geometry.?.src.h = 9; + const height_fragment = kittyPlacement(height, 80, 16, 640, 256) orelse + return error.MissingSafeKittyHeightFragment; + try std.testing.expectEqual(@as(u16, 1), height_fragment.options.size.?.rows.?); + try std.testing.expect(@as(u32, height_fragment.options.size.?.rows.?) * 16 <= + height.native.geometry.?.dst.h); + + // There is no honest APC for less than one physical row: omitting it is + // preferable to painting three pixels of the following theme gap. + height.native.geometry.?.dst.h = 13; + try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null); +} + fn kittyImageRepresentable(place: pardes.ImagePlace) bool { return place.iw > 0 and place.ih > 0 and place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16); } +fn surfaceHasKittyKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool { + for (surface.images[0..surface.nimages]) |maybe| { + const place = maybe orelse continue; + if (comptime pardes.pdf_enabled) if (!kittyImageRepresentable(place)) continue; + if (place.cacheKey().eql(key)) return true; + } + return false; +} + const PdfWheelTarget = struct { pane: usize, page: usize }; /// A native PDF's page geometry is invalid between `setPdfPage` and the next @@ -253,12 +375,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); - const KittyCached = struct { key: pardes.ImageCacheKey, image: vaxis.Image }; - var kitty_handles: [pardes.MAX_PANES]?KittyCached = @splat(null); - defer for (&kitty_handles) |*slot| if (slot.*) |cached| { - vx.freeImage(tty.writer(), cached.image.id); - slot.* = null; - }; + var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa); + defer { + var iterator = kitty_handles.valueIterator(); + while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); + kitty_handles.deinit(); + } var ptys: [pardes.MAX_PANES]?Pty = @splat(null); // per-slot spawn generation: a reused pane id ignores the old shell's // late pty_eof (which would otherwise close the NEW pty on that slot) @@ -520,10 +642,9 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // `watch off` effects go into a queue nobody drains — drop the lot // here. The new core emits its own `on`s as it builds its panes. for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0); - for (&kitty_handles) |*slot| if (slot.*) |cached| { - vx.freeImage(tty.writer(), cached.image.id); - slot.* = null; - }; + var image_iterator = kitty_handles.valueIterator(); + while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); + kitty_handles.clearRetainingCapacity(); nc.native_images = vx.caps.kitty_graphics; core.deinit(); core = nc; @@ -583,37 +704,32 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { tz_cells.end(); // Pixel attachments (kitty graphics): transmit once per pixel // generation, then re-place every frame (placements aren't - // persistent). A slot can be closed and reused for an unrelated pane, - // and a live document can render new pixels, so neither the pane - // number nor its lifetime serial is a complete cache key by itself. - var kitty_seen: [pardes.MAX_PANES]bool = @splat(false); + // persistent). The map is keyed by pane lifetime + PDF page + pixel + // revision, so any number of short visible pages can coexist without + // aliasing a fixed terminal cache slot. for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (comptime pardes.pdf_enabled) { if (!kittyImageRepresentable(place)) continue; } - kitty_seen[place.pane] = true; - if (kitty_handles[place.pane]) |cached| if (!cached.key.eql(place.cacheKey())) { - vx.freeImage(tty.writer(), cached.image.id); - kitty_handles[place.pane] = null; - }; - if (kitty_handles[place.pane] == null and vx.caps.kitty_graphics) { + const key = place.cacheKey(); + if (!kitty_handles.contains(key) and vx.caps.kitty_graphics) { const enc = std.base64.standard.Encoder; if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { defer gpa.free(b64); _ = enc.encode(b64, place.rgba); if (vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle| - kitty_handles[place.pane] = .{ .key = place.cacheKey(), .image = handle }; + kitty_handles.put(key, handle) catch vx.freeImage(tty.writer(), handle.id); } else |_| {} } - if (kitty_handles[place.pane]) |cached| { + if (kitty_handles.get(key)) |cached| { if (comptime !pardes.pdf_enabled) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); - cached.image.draw(child, .{ .scale = .contain }) catch {}; + cached.draw(child, .{ .scale = .contain }) catch {}; } else { if (place.native.fit == .contain) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); - cached.image.draw(child, .{ .scale = .contain }) catch {}; + cached.draw(child, .{ .scale = .contain }) catch {}; } else if (kittyPlacement( place, vx.screen.width, @@ -624,10 +740,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { const child = win.child(.{ .x_off = @as(i17, place.x) + placement.cell_x, .y_off = @as(i17, place.y) + placement.cell_y, - .width = place.w - placement.cell_x, - .height = place.h - placement.cell_y, + .width = placement.cell_cols, + .height = placement.cell_rows, }); - cached.image.draw(child, placement.options) catch {}; + cached.draw(child, placement.options) catch {}; } } } @@ -635,10 +751,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // Toggling PETSCII or closing a pane removes its attachment from the // Surface. Release the terminal-side image then, not merely when that // numeric pane slot happens to be reused. - for (&kitty_handles, kitty_seen) |*slot, seen| if (!seen) if (slot.*) |cached| { - vx.freeImage(tty.writer(), cached.image.id); - slot.* = null; - }; + var stale: std.ArrayList(pardes.ImageCacheKey) = .empty; + defer stale.deinit(gpa); + var image_iterator = kitty_handles.iterator(); + while (image_iterator.next()) |entry| + if (!surfaceHasKittyKey(surface, entry.key_ptr.*)) + stale.append(gpa, entry.key_ptr.*) catch {}; + for (stale.items) |key| if (kitty_handles.fetchRemove(key)) |removed| + vx.freeImage(tty.writer(), removed.value.id); if (surface.cursor) |cur| { win.showCursor(cur.x, cur.y); // insert = beam, everything else = the terminal's default shape |
