summaryrefslogtreecommitdiff
path: root/src/tty
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty')
-rw-r--r--src/tty/tty.zig196
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