summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/TerminalImages.zig247
-rw-r--r--src/body_layer.zig1
-rw-r--r--src/gui/gui.zig2
-rw-r--r--src/host_io.zig13
-rw-r--r--src/surface.zig4
-rw-r--r--src/terminal.zig177
-rw-r--r--src/tty/tty.zig128
-rw-r--r--test/yazi_preview.py145
8 files changed, 713 insertions, 4 deletions
diff --git a/src/TerminalImages.zig b/src/TerminalImages.zig
new file mode 100644
index 00000000..773297b2
--- /dev/null
+++ b/src/TerminalImages.zig
@@ -0,0 +1,247 @@
+//! A terminal pane's kitty graphics: the images its program sent (yazi's
+//! previews, `kitten icat`) drawn where it placed them. ghostty-vt keeps
+//! the images and placements; this reads them each frame, cuts each one
+//! into the cell rows it covers, clipped to the rows the pane shows, and
+//! hands them on as pixels (`ImagePlace`, exact geometry) to a shell that
+//! draws them.
+//! Both kinds of placement are drawn: one on a cell (`a=T`/`a=p`), which
+//! moves with the text it was put on, and a virtual one (`U=1`) shown
+//! through the U+10EEEE placeholder cells the program prints.
+const std = @import("std");
+const ghostty_vt = @import("ghostty-vt");
+const pardes = @import("pardes.zig");
+const image = @import("image.zig");
+
+const TerminalImages = @This();
+const gfx = ghostty_vt.kitty.graphics;
+const PixelRect = image.PixelRect;
+
+/// The bytes ghostty-vt may hold for one screen's images (it evicts the
+/// oldest past this): a 4K picture in RGBA, with room for a second.
+pub const storage_limit = 64 * 1024 * 1024;
+
+/// Pixels in RGBA for images ghostty-vt holds in another format (RGB,
+/// grey): only for images on screen, dropped the frame they are not.
+converted: std.AutoHashMapUnmanaged(u32, Converted) = .empty,
+
+const Converted = struct { generation: u64, rgba: []u8, used: bool = true };
+
+/// One image's rectangle in the pane, in pixels from the viewport's top
+/// left corner (above or left of it is negative).
+const Rect = struct { x: i64, y: i64, w: i64, h: i64 };
+
+pub fn deinit(ti: *TerminalImages, gpa: std.mem.Allocator) void {
+ var conv = ti.converted.valueIterator();
+ while (conv.next()) |c| gpa.free(c.rgba);
+ ti.converted.deinit(gpa);
+}
+
+/// Accept PNG (`f=100`) as well as raw pixels: ghostty-vt built as a
+/// library decodes none itself. stb_image does it, as for image panes.
+pub fn installPngDecoder() void {
+ ghostty_vt.sys.decode_png = &decodePng;
+}
+
+fn decodePng(gpa: std.mem.Allocator, bytes: []const u8) ghostty_vt.sys.DecodeError!ghostty_vt.sys.Image {
+ const decoded = image.decode(gpa, bytes) orelse return error.InvalidData;
+ return .{ .width = @intCast(decoded.w), .height = @intCast(decoded.h), .data = decoded.rgba };
+}
+
+/// Draw the active screen's images. `rows_of[v]` is the body row the
+/// viewport's row `v` is shown on, if it is shown; `cols` how many of its
+/// columns the body shows, from `tx`.
+pub fn draw(
+ ti: *TerminalImages,
+ p: *pardes.Pardes,
+ s: *pardes.Surface,
+ vt: *ghostty_vt.Terminal,
+ pane_id: u8,
+ serial: u32,
+ tx: u16,
+ body_y: u16,
+ cols: u16,
+ rows_of: []const ?u16,
+) void {
+ var conv = ti.converted.valueIterator();
+ while (conv.next()) |c| c.used = false;
+ defer ti.prune(p.gpa);
+
+ const screen = vt.screens.active;
+ const storage = &screen.kitty_images;
+ const pages = &screen.pages;
+ const top = pages.getTopLeft(.viewport);
+ const bottom = pages.getBottomRight(.viewport) orelse return;
+ const cw: u32 = @max(1, p.cell_pixels.w);
+ const ch: u32 = @max(1, p.cell_pixels.h);
+ const ctx: Ctx = .{ .ti = ti, .p = p, .pane = pane_id, .serial = serial, .tx = tx, .body_y = body_y, .cols = cols, .rows_of = rows_of, .cw = cw, .ch = ch };
+
+ // Virtual placements: shown through the placeholder cells, one run of
+ // them on a row at a time. The placeholders themselves are not text.
+ var runs = gfx.unicode.placementIterator(top, bottom);
+ while (runs.next()) |run| {
+ const at = pages.pointFromPin(.viewport, run.pin) orelse continue;
+ const v = at.viewport.y;
+ if (v >= rows_of.len) continue;
+ const row = rows_of[v] orelse continue;
+ for (@as(usize, at.viewport.x)..@as(usize, at.viewport.x) + run.width) |x| {
+ if (x >= cols) break;
+ const cell = s.at(tx + @as(u16, @intCast(x)), body_y + row);
+ cell.text[0] = ' ';
+ cell.len = 1;
+ }
+ const img = storage.imageById(run.image_id) orelse continue;
+ const rp = run.renderPlacement(storage, &img, cw, ch) catch continue;
+ if (rp.dest_width == 0 or rp.dest_height == 0 or rp.source_width == 0 or rp.source_height == 0) continue;
+ ctx.place(img, .{ .x = rp.source_x, .y = rp.source_y, .w = rp.source_width, .h = rp.source_height }, .{
+ .x = @as(i64, at.viewport.x) * cw + rp.offset_x,
+ .y = @as(i64, v) * ch + rp.offset_y,
+ .w = rp.dest_width,
+ .h = rp.dest_height,
+ });
+ }
+
+ // Placements on a cell: they move with the text they were put on, and
+ // go where it goes, above the viewport or off the screen.
+ const origin = pages.pointFromPin(.screen, top) orelse return;
+ var it = storage.placements.iterator();
+ while (it.next()) |entry| {
+ const placed = entry.value_ptr.*;
+ const pin = switch (placed.location) {
+ .pin => |pin| pin,
+ .virtual => continue,
+ };
+ const img = storage.images.get(entry.key_ptr.image_id) orelse continue;
+ const at = pages.pointFromPin(.screen, pin.*) orelse continue;
+ const size = placed.pixelSize(img, vt);
+ const sx = @min(placed.source_x, img.width);
+ const sy = @min(placed.source_y, img.height);
+ const sw = if (placed.source_width > 0) @min(placed.source_width, img.width - sx) else img.width - sx;
+ const sh = if (placed.source_height > 0) @min(placed.source_height, img.height - sy) else img.height - sy;
+ if (size.width == 0 or size.height == 0 or sw == 0 or sh == 0) continue;
+ ctx.place(img, .{ .x = sx, .y = sy, .w = sw, .h = sh }, .{
+ .x = @as(i64, at.screen.x) * cw + placed.x_offset,
+ .y = (@as(i64, at.screen.y) - @as(i64, origin.screen.y)) * ch + placed.y_offset,
+ .w = size.width,
+ .h = size.height,
+ });
+ }
+}
+
+const Ctx = struct {
+ ti: *TerminalImages,
+ p: *pardes.Pardes,
+ pane: u8,
+ serial: u32,
+ tx: u16,
+ body_y: u16,
+ cols: u16,
+ rows_of: []const ?u16,
+ cw: u32,
+ ch: u32,
+
+ /// Draw `src` of `img` over `dst`, one cell row of it at a time: a row
+ /// is shown, or not, or shown elsewhere, as the body lays it out.
+ fn place(ctx: Ctx, img: gfx.Image, src: PixelRect, dst: Rect) void {
+ const cw: i64 = ctx.cw;
+ const ch: i64 = ctx.ch;
+ const x0 = @max(dst.x, 0);
+ const x1 = @min(dst.x + dst.w, @as(i64, ctx.cols) * cw);
+ if (x1 <= x0) return;
+ const rgba = ctx.ti.pixelsOf(ctx.p.gpa, img) orelse return;
+ const first_row = @divFloor(@max(dst.y, 0), ch);
+ const last_row = @min(@divFloor(dst.y + dst.h - 1, ch), @as(i64, @intCast(ctx.rows_of.len)) - 1);
+ var v = first_row;
+ while (v <= last_row) : (v += 1) {
+ const row = ctx.rows_of[@intCast(v)] orelse continue;
+ const y0 = @max(dst.y, v * ch);
+ const y1 = @min(dst.y + dst.h, (v + 1) * ch);
+ if (y1 <= y0) continue;
+ // The source pixels under this piece, by the same scale.
+ const piece: PixelRect = .{
+ .x = src.x + @as(u32, @intCast(@divFloor((x0 - dst.x) * src.w, dst.w))),
+ .y = src.y + @as(u32, @intCast(@divFloor((y0 - dst.y) * src.h, dst.h))),
+ .w = @max(1, @as(u32, @intCast(@divFloor((x1 - x0) * src.w, dst.w)))),
+ .h = @max(1, @as(u32, @intCast(@divFloor((y1 - y0) * src.h, dst.h)))),
+ };
+ const c0: u16 = @intCast(@divFloor(x0, cw));
+ const c1: u16 = @intCast(@divFloor(x1 + cw - 1, cw));
+ _ = ctx.p.appendImagePlace(.{
+ .pane = ctx.pane,
+ .serial = ctx.serial,
+ .native = .{
+ .page = img.id,
+ .revision = @truncate(img.generation),
+ .exact = true,
+ .geometry = .{ .src = piece, .dst = .{
+ .x = @intCast(x0 - @as(i64, c0) * cw),
+ .y = @intCast(y0 - v * ch),
+ .w = @intCast(x1 - x0),
+ .h = @intCast(y1 - y0),
+ } },
+ },
+ .x = ctx.tx + c0,
+ .y = ctx.body_y + row,
+ .w = c1 - c0,
+ .h = 1,
+ .rgba = rgba,
+ .iw = img.width,
+ .ih = img.height,
+ });
+ }
+ }
+};
+
+/// The image's pixels as RGBA: its own bytes when it is held so.
+fn pixelsOf(ti: *TerminalImages, gpa: std.mem.Allocator, img: gfx.Image) ?[]const u8 {
+ const n = @as(usize, img.width) * img.height;
+ if (n == 0) return null;
+ if (img.format == .rgba) return if (img.data.len == n * 4) img.data else null;
+ const gop = ti.converted.getOrPut(gpa, img.id) catch return null;
+ if (gop.found_existing) {
+ if (gop.value_ptr.generation == img.generation) {
+ gop.value_ptr.used = true;
+ return gop.value_ptr.rgba;
+ }
+ gpa.free(gop.value_ptr.rgba);
+ _ = ti.converted.remove(img.id);
+ return ti.pixelsOf(gpa, img);
+ }
+ const out = convert(gpa, img, n) orelse {
+ _ = ti.converted.remove(img.id);
+ return null;
+ };
+ gop.value_ptr.* = .{ .generation = img.generation, .rgba = out };
+ return out;
+}
+
+fn convert(gpa: std.mem.Allocator, img: gfx.Image, n: usize) ?[]u8 {
+ const depth: usize = switch (img.format) {
+ .rgb => 3,
+ .gray_alpha => 2,
+ .gray => 1,
+ else => return null,
+ };
+ if (img.data.len != n * depth) return null;
+ const out = gpa.alloc(u8, n * 4) catch return null;
+ for (0..n) |i| {
+ const px = img.data[i * depth ..][0..depth];
+ out[i * 4 ..][0..4].* = switch (depth) {
+ 3 => .{ px[0], px[1], px[2], 255 },
+ 2 => .{ px[0], px[0], px[0], px[1] },
+ else => .{ px[0], px[0], px[0], 255 },
+ };
+ }
+ return out;
+}
+
+/// Drop what this frame did not draw: the caches hold only what is shown.
+fn prune(ti: *TerminalImages, gpa: std.mem.Allocator) void {
+ while (true) {
+ var conv = ti.converted.iterator();
+ const stale = while (conv.next()) |entry| {
+ if (!entry.value_ptr.used) break entry.key_ptr.*;
+ } else break;
+ gpa.free(ti.converted.get(stale).?.rgba);
+ _ = ti.converted.remove(stale);
+ }
+}
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 914694e7..b24bd6ed 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -306,6 +306,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
panes.File.recolorDiffOutput(p, s, pane, r, tx, tw, body_h, body);
}
tz_color.end();
+ if (pane.isTerminal()) panes.terminal.drawImages(p, s, pane, id, tx, tw, body_y, body_h);
const sel_btn = buttonTints(th);
if (p.look_hover_preview) |preview| {
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index ea0e2862..739e5a69 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4741,7 +4741,7 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
const s = shellOf(ctx);
if (s.ptys[pane]) |pt| {
if (pt.fd < 0) return;
- const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const ws = host_io.ptyWinsize(s.core, cols, rows);
_ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws));
}
}
diff --git a/src/host_io.zig b/src/host_io.zig
index 4ebac9ee..b138a840 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -1409,6 +1409,17 @@ fn exportWinid(serial: u32) void {
/// The longest line a command pane runs (exec.zig refuses a longer one).
pub const exec_command_max = pardes.exec.command_max;
+/// A pane's pty size, in cells and in pixels: a program sizes its kitty
+/// graphics by the pixels (yazi's previews; pardes nested in a pane).
+pub fn ptyWinsize(core: ?*const pardes.Pardes, cols: u16, rows: u16) posix.winsize {
+ var ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ if (comptime pardes.pdf_enabled) if (core) |c| {
+ ws.xpixel = std.math.cast(u16, @as(u32, cols) * c.cell_pixels.w) orelse 0;
+ ws.ypixel = std.math.cast(u16, @as(u32, rows) * c.cell_pixels.h) orelse 0;
+ };
+ return ws;
+}
+
pub fn forkShell(
core: ?*pardes.Pardes,
pane: usize,
@@ -1493,7 +1504,7 @@ pub fn forkShell(
.command
else if (core) |c| (if (c.settings.pager == .off) .terminal_off else .terminal) else .terminal;
const envp = ChildEnv.build(&env_slots, paging);
- const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const ws = ptyWinsize(core, cols, rows);
// The child says here why it could not become the shell (its chdir or
// its exec failed, as for a script whose interpreter is not there): the
// pipe closes on exec, so the parent reads nothing when the shell runs,
diff --git a/src/surface.zig b/src/surface.zig
index d1ea78b1..a08d6831 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -240,6 +240,10 @@ pub const NativePlacement = if (pdf_enabled) struct {
pixel_offset_y: f32 = 0,
/// Separate paper behind straight-alpha PDF content; null for other images.
paper_bg: ?[3]u8 = null,
+ /// `geometry` is the whole of it: `src` drawn into `dst`, scaled as
+ /// it says. A terminal pane's kitty graphics come this way, one cell
+ /// row at a time, already clipped (TerminalImages.zig).
+ exact: bool = false,
} else struct {};
pub const ImagePatch = struct { from: u32, y: u32, h: u32 };
diff --git a/src/terminal.zig b/src/terminal.zig
index c0341d00..e6304c0a 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -20,6 +20,10 @@ const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {};
pub const history_max = if (enabled) 64 else 8;
pub const VtSlot = if (enabled) ghostty_vt.Terminal else void;
+/// Kitty graphics are drawn where pixel geometry is (pdf_enabled, as for
+/// image panes' native placements).
+pub const images_enabled = enabled and pardes.pdf_enabled;
+pub const TerminalImages = if (images_enabled) @import("TerminalImages.zig") else void;
pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void;
/// The `aid` pardes's injected bash and fish marks carry (host_io.zig).
@@ -127,6 +131,8 @@ pub const State = struct {
/// The grid cell the program last heard the mouse at, so motion is
/// reported once per cell.
mouse_cell: if (enabled) ?ghostty_vt.Coordinate else void = if (enabled) null else {},
+ /// The kitty graphics its program placed, as drawn (TerminalImages.zig).
+ images: TerminalImages = if (images_enabled) .{} else {},
};
const GColor = ghostty_vt.color;
@@ -560,6 +566,7 @@ pub fn screenTextLen(pane: *Pane) usize {
pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void {
if (comptime !enabled) return;
const state = pane.terminal orelse return;
+ if (comptime images_enabled) state.images.deinit(gpa);
state.stream.deinit();
state.vt.deinit(gpa);
gpa.destroy(state);
@@ -578,13 +585,18 @@ pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
.cols = cols,
.rows = rows,
.max_scrollback = 16 * 1024 * 1024,
+ .kitty_image_storage_limit = if (images_enabled) TerminalImages.storage_limit else 0,
});
state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() });
state.replay.head = 0;
state.replay.len = 0;
state.reply_len = 0;
+ state.mouse_cell = null;
+ state.images = if (images_enabled) .{} else {};
+ if (comptime images_enabled) TerminalImages.installPngDecoder();
state.stream.handler.inner.effects.write_pty = ptyReport;
state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs;
+ state.stream.handler.inner.effects.size = ptySize;
pane.terminal = state;
pane.tty_filter = true;
return pane;
@@ -874,6 +886,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
// viewport down only when the viewport was already on the last row, so
// a reader scrolled back into the scrollback stays where they scrolled.
const at_bottom = atBottom(pane);
+ syncPixels(p, pane);
if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes);
p.shell_rows.markStale(pane);
if (pane.body.mode == .tty and at_bottom) followOutput(pane);
@@ -2766,6 +2779,59 @@ pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
return .{};
}
+/// CSI 14/16/18 t: the pane's size, and its cells' in pixels, which a
+/// program sizes its kitty graphics by (yazi asks 16 t). Unknown, not asked.
+fn ptySize(handler: *ghostty_vt.TerminalStream.Handler) ?ghostty_vt.size_report.Size {
+ const t = handler.terminal;
+ if (t.width_px == 0 or t.height_px == 0 or t.cols == 0 or t.rows == 0) return null;
+ return .{ .rows = t.rows, .columns = t.cols, .cell_width = t.width_px / t.cols, .cell_height = t.height_px / t.rows };
+}
+
+/// The emulator's size in pixels follows the shell's cells: kitty graphics
+/// placements are sized by it, and CSI 14/16 t report it.
+fn syncPixels(p: *const Pardes, pane: *Pane) void {
+ if (comptime !images_enabled) return;
+ const state = pane.terminal orelse return;
+ const vt = &state.vt;
+ vt.width_px = @as(u32, vt.cols) * p.cell_pixels.w;
+ vt.height_px = @as(u32, vt.rows) * p.cell_pixels.h;
+}
+
+/// Draw the pane's kitty graphics images over its body rows (after the
+/// rows and their colours: placeholder cells are blanked here).
+pub fn drawImages(p: *Pardes, s: *Surface, pane: *Pane, id: usize, tx: u16, tw: u16, body_y: u16, body_h: u16) void {
+ if (comptime !images_enabled) return;
+ const state = pane.terminal orelse return;
+ const screen = state.vt.screens.active;
+ syncPixels(p, pane);
+ // A body taller than this draws its images on the rows that fit.
+ var rows_of: [1024]?u16 = undefined;
+ const vp_rows: usize = @min(scrollbar(pane).len, rows_of.len);
+ @memset(rows_of[0..vp_rows], null);
+ var any_placeholder = false;
+ var walk: BodyWalk = .init(pane);
+ var vr: u16 = 0;
+ while (walk.next()) |row| : (vr += 1) {
+ if (vr >= body_h) break;
+ const vi = switch (row) {
+ .grid => |v| v,
+ .edit => continue,
+ };
+ if (vi < 0 or vi >= vp_rows) continue;
+ const row_pin = screen.pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue;
+ const flags = row_pin.rowAndCell().row;
+ // A prompt row outside tty mode is cut or hidden: its columns are
+ // not the grid's.
+ if (pane.body.mode != .tty and flags.semantic_prompt != .none) continue;
+ any_placeholder = any_placeholder or flags.kitty_virtual_placeholder;
+ rows_of[@intCast(vi)] = vr;
+ }
+ // Nothing to draw, no placeholder to blank and nothing cached to let
+ // go: the common case costs the rows' flags.
+ if (!any_placeholder and screen.kitty_images.placements.count() == 0 and state.images.converted.count() == 0) return;
+ state.images.draw(p, s, &state.vt, @intCast(id), pane.serial, tx, body_y, @min(tw, pane.cols), rows_of[0..vp_rows]);
+}
+
/// Every byte the core wrote to each pane's pty since the last call, `\n`
/// between panes' shares: "0:<bytes>\n1:<bytes>\n" for panes that got any.
fn ptyWritesByPane(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 {
@@ -2817,3 +2883,114 @@ test "a program that asked for focus reports hears CSI I and CSI O as its pane t
p.update(.tick);
try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out));
}
+
+/// `a=T` of a 4x2 RGB image, `i=7`, red on the left half, blue on the
+/// right, with the rest of the control data `extra` (`c=2,r=1` places it
+/// over two cells).
+inline fn kittyTransmit(comptime extra: []const u8) []const u8 {
+ return comptime blk: {
+ const pixels = [_]u8{ 255, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 255 } ** 2;
+ var buf: [std.base64.standard.Encoder.calcSize(pixels.len)]u8 = undefined;
+ const b64 = std.base64.standard.Encoder.encode(&buf, &pixels)[0..buf.len].*;
+ break :blk "\x1b_Ga=T,f=24,s=4,v=2,i=7," ++ extra ++ ";" ++ b64 ++ "\x1b\\";
+ };
+}
+
+fn imagePlaces(s: *const Surface) []const ?pardes.ImagePlace {
+ return s.images[0..s.nimages];
+}
+
+test "a terminal's kitty graphics image is drawn where its program placed it, and a delete takes it away" {
+ if (comptime !images_enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .shells = 1 });
+ defer p.deinit();
+ p.native_images = true;
+ p.active = 0;
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24, .cell_pixels = .{ .w = 10, .h = 20 } } });
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const pane = p.panes[0].?;
+ pane.body.mode = .tty;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3;5H" ++ kittyTransmit("c=2,r=1") } });
+ var s = try p.render(frame.allocator());
+ const places = imagePlaces(s);
+ try std.testing.expectEqual(@as(usize, 1), places.len);
+ const place = places[0].?;
+ const r = p.rects[0];
+ try std.testing.expect(place.native.exact);
+ try std.testing.expectEqual(@as(u32, 7), place.native.page);
+ try std.testing.expectEqual(pane.serial, place.serial);
+ try std.testing.expectEqual(r.x + config.GUTTER + 4, place.x);
+ try std.testing.expectEqual(p.bodyTop(pane, r) + 2, place.y);
+ try std.testing.expectEqual(@as(u16, 2), place.w);
+ try std.testing.expectEqual(@as(u16, 1), place.h);
+ try std.testing.expectEqual(@as(usize, 4), place.iw);
+ try std.testing.expectEqual(@as(usize, 2), place.ih);
+ try std.testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, place.rgba[0..4]);
+ const g = place.native.geometry.?;
+ try std.testing.expectEqual(pardes.image.PixelRect{ .x = 0, .y = 0, .w = 4, .h = 2 }, g.src);
+ try std.testing.expectEqual(pardes.image.PixelRect{ .x = 0, .y = 0, .w = 20, .h = 20 }, g.dst);
+
+ // Scrolled up by the shell's output, it goes up with the text.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[24;1H\n\n" } });
+ _ = frame.reset(.retain_capacity);
+ s = try p.render(frame.allocator());
+ try std.testing.expectEqual(p.bodyTop(pane, r), imagePlaces(s)[0].?.y);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\n" } });
+ _ = frame.reset(.retain_capacity);
+ s = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), imagePlaces(s).len);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[1;1H" ++ kittyTransmit("c=2,r=1") } });
+ _ = frame.reset(.retain_capacity);
+ s = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 1), imagePlaces(s).len);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b_Ga=d,d=A\x1b\\" } });
+ _ = frame.reset(.retain_capacity);
+ s = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), imagePlaces(s).len);
+}
+
+test "a virtual kitty placement shows through its placeholder cells, which draw no text" {
+ if (comptime !images_enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .shells = 1 });
+ defer p.deinit();
+ p.native_images = true;
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24, .cell_pixels = .{ .w = 10, .h = 20 } } });
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const pane = p.panes[0].?;
+ pane.body.mode = .tty;
+ // yazi's way: a virtual placement two cells by one, then the two
+ // placeholders, the image's id in their colour, row and column in
+ // their diacritics.
+ p.update(.{ .output = .{ .pane = 0, .bytes = kittyTransmit("U=1,c=2,r=1,q=2") ++
+ "\x1b[2;3H\x1b[38;2;0;0;7m\u{10EEEE}\u{0305}\u{0305}\u{10EEEE}\u{0305}\u{030D}\x1b[39mX" } });
+ const s = try p.render(frame.allocator());
+ const places = imagePlaces(s);
+ try std.testing.expectEqual(@as(usize, 1), places.len);
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const y = p.bodyTop(pane, r) + 1;
+ try std.testing.expectEqual(tx + 2, places[0].?.x);
+ try std.testing.expectEqual(y, places[0].?.y);
+ try std.testing.expectEqual(@as(u16, 2), places[0].?.w);
+ try std.testing.expectEqualStrings(" ", s.at(tx + 2, y).grapheme());
+ try std.testing.expectEqualStrings(" ", s.at(tx + 3, y).grapheme());
+ try std.testing.expectEqualStrings("X", s.at(tx + 4, y).grapheme());
+}
+
+test "a program asking the cell size in pixels (CSI 16 t) is told the shell's" {
+ if (comptime !images_enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .shells = 1 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24, .cell_pixels = .{ .w = 10, .h = 20 } } });
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ _ = try ptyWritesByPane(p, &out);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[16t" } });
+ try std.testing.expectEqualStrings("0:\x1b[6;20;10t\n", try ptyWritesByPane(p, &out));
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index a01df94b..a067ffce 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -795,6 +795,63 @@ fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u
} });
}
+/// A terminal pane's image fragment (`native.exact`): its `src` into its
+/// `dst`, inside one cell row. Kitty scales a source rectangle to whole
+/// cells, so the source is trimmed to the cells' scale rather than the
+/// picture stretched: a fragment loses under a cell at its right edge.
+/// A full row is declared one row high; a part of one (an image's top or
+/// bottom edge) only its columns, its height then following the source.
+fn exactKittyPlacement(
+ place: pardes.ImagePlace,
+ screen_cols: u16,
+ screen_rows: u16,
+ screen_pixel_w: u16,
+ screen_pixel_h: u16,
+) ?KittyPlacement {
+ if (comptime !pardes.pdf_enabled) return null;
+ const g = place.native.geometry orelse return null;
+ const cell_w = terminalCellPixels(screen_pixel_w, screen_cols, 8);
+ const cell_h = terminalCellPixels(screen_pixel_h, screen_rows, 16);
+ if (g.dst.w == 0 or g.dst.h == 0 or g.src.w == 0 or g.src.h == 0) return null;
+ const cell_x = g.dst.x / cell_w;
+ const pixel_x = g.dst.x % cell_w;
+ const pixel_y = @min(g.dst.y, cell_h - 1);
+ const cols: u32 = @max(1, (g.dst.w + cell_w / 2) / cell_w);
+ if (cell_x >= place.w) return null;
+ const declared_w = cols * cell_w;
+ // At the cells' scale, the source that fills `cols` cells.
+ var src_w = g.src.w;
+ if (declared_w < g.dst.w) src_w = @max(1, @as(u32, @intCast(@as(u64, g.src.w) * declared_w / g.dst.w)));
+ var src_h = g.src.h;
+ const full = g.dst.y == 0 and g.dst.h >= cell_h;
+ if (!full) {
+ // As tall as it is at that scale, and no taller than the row.
+ const room = cell_h - pixel_y;
+ const fits: u32 = @intCast(@as(u64, room) * src_w / declared_w);
+ if (fits == 0) return null;
+ src_h = @min(src_h, fits);
+ }
+ return .{
+ .cell_x = std.math.cast(u16, cell_x) orelse return null,
+ .cell_y = 0,
+ .cell_cols = std.math.cast(u16, @min(@as(u32, place.w) - cell_x, (pixel_x + declared_w + cell_w - 1) / cell_w)) orelse return null,
+ .cell_rows = 1,
+ .options = .{
+ .clip_region = .{
+ .x = std.math.cast(u16, g.src.x) orelse return null,
+ .y = std.math.cast(u16, g.src.y) orelse return null,
+ .width = std.math.cast(u16, src_w) orelse return null,
+ .height = std.math.cast(u16, src_h) orelse return null,
+ },
+ .pixel_offset = if (pixel_x != 0 or pixel_y != 0) .{ .x = @intCast(pixel_x), .y = @intCast(pixel_y) } else null,
+ .size = if (full)
+ .{ .cols = std.math.cast(u16, cols) orelse return null, .rows = 1 }
+ else
+ .{ .cols = std.math.cast(u16, cols) orelse return null },
+ },
+ };
+}
+
fn kittyPlacement(
place: pardes.ImagePlace,
screen_cols: u16,
@@ -901,6 +958,61 @@ fn kittyPlacement(
}
}
+test "a terminal's image fragment goes to kitty cropped to its cells' scale, a full row one row high" {
+ if (comptime !pardes.pdf_enabled) return;
+ var place = pardes.ImagePlace{
+ .pane = 0,
+ .serial = 3,
+ .native = .{ .page = 7, .exact = true, .geometry = .{
+ .src = .{ .x = 0, .y = 40, .w = 240, .h = 20 },
+ .dst = .{ .x = 0, .y = 0, .w = 240, .h = 20 },
+ } },
+ .x = 99,
+ .y = 4,
+ .w = 24,
+ .h = 1,
+ .rgba = &.{},
+ .iw = 240,
+ .ih = 160,
+ };
+ // 10x20 pixel cells: a whole row, one to one.
+ const full = exactKittyPlacement(place, 100, 30, 1000, 600).?;
+ try std.testing.expectEqual(@as(u16, 0), full.cell_x);
+ try std.testing.expectEqual(@as(?u16, 24), full.options.size.?.cols);
+ try std.testing.expectEqual(@as(?u16, 1), full.options.size.?.rows);
+ try std.testing.expectEqual(@as(?u16, 40), full.options.clip_region.?.y);
+ try std.testing.expectEqual(@as(?u16, 240), full.options.clip_region.?.width);
+ try std.testing.expect(full.options.pixel_offset == null);
+
+ // The top of a letterboxed picture: 7 pixels down into the row, 13
+ // tall, 35 pixels in, 205 wide, which rounds to 21 cells. Its columns
+ // only, its source no taller than the 13 pixels left in the row at
+ // that scale.
+ place.native.geometry = .{
+ .src = .{ .x = 0, .y = 0, .w = 205, .h = 13 },
+ .dst = .{ .x = 35, .y = 7, .w = 205, .h = 13 },
+ };
+ const top = exactKittyPlacement(place, 100, 30, 1000, 600).?;
+ try std.testing.expectEqual(@as(u16, 3), top.cell_x);
+ try std.testing.expectEqual(@as(u16, 5), top.options.pixel_offset.?.x);
+ try std.testing.expectEqual(@as(u16, 7), top.options.pixel_offset.?.y);
+ try std.testing.expectEqual(@as(?u16, 21), top.options.size.?.cols);
+ try std.testing.expectEqual(@as(?u16, null), top.options.size.?.rows);
+ const w = top.options.clip_region.?.width.?;
+ const h = top.options.clip_region.?.height.?;
+ try std.testing.expectEqual(@as(u16, 205), w);
+ try std.testing.expect(h > 0 and @as(u32, h) * 210 / w <= 13);
+
+ // Narrower than its cells round to: the source is cut to their scale.
+ place.native.geometry = .{
+ .src = .{ .x = 0, .y = 0, .w = 400, .h = 40 },
+ .dst = .{ .x = 0, .y = 0, .w = 204, .h = 20 },
+ };
+ const cut = exactKittyPlacement(place, 100, 30, 1000, 600).?;
+ try std.testing.expectEqual(@as(?u16, 20), cut.options.size.?.cols);
+ try std.testing.expectEqual(@as(?u16, 392), cut.options.clip_region.?.width);
+}
+
test "Kitty PDF fragments never declare pixels beyond their clipped bottom" {
if (comptime !pardes.pdf_enabled) return;
@@ -1696,7 +1808,19 @@ const Shell = struct {
const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h });
cached.draw(child, .{ .scale = .contain }) catch {};
} else {
- if (place.native.fit == .contain) {
+ if (place.native.exact) {
+ if (exactKittyPlacement(place, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix)) |placement| {
+ // The cell keeps its own glyph and colours
+ // (Image.draw would reset them): a fragment
+ // short of the cell shows the pane under it.
+ const x = place.x + placement.cell_x;
+ if (win.readCell(x, place.y)) |under| {
+ var cell = under;
+ cell.image = .{ .img_id = cached.id, .options = placement.options };
+ win.writeCell(x, place.y, cell);
+ }
+ }
+ } 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.draw(child, .{ .scale = .contain }) catch {};
} else if (kittyPlacement(
@@ -1778,7 +1902,7 @@ const Shell = struct {
fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
const s = of(ctx);
if (s.ptys[pane]) |pt| {
- const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const ws = host_io.ptyWinsize(s.core, cols, rows);
_ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws));
}
}
diff --git a/test/yazi_preview.py b/test/yazi_preview.py
new file mode 100644
index 00000000..7fa02aa6
--- /dev/null
+++ b/test/yazi_preview.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""yazi's image and PDF previews show in a terminal pane, and go with it.
+
+python3 -B test/yazi_preview.py <pardes-gui> <output dir> [--tty <pardes>]
+
+A hidden PARDES_TEST window runs yazi in a terminal pane on a directory
+holding a PNG and a PDF; its captures must show the picture's pixels in
+the preview column, then the PDF's page, then nothing once yazi quits.
+With --tty, the same yazi runs under a tty pardes nested in that pane: the
+tty passes the images on as kitty graphics, which the window draws.
+The captures are left in the output directory. Without yazi it skips.
+"""
+import argparse
+import os
+import shutil
+import struct
+import time
+import zlib
+from pathlib import Path
+
+from fs import session, new_pane, execute
+from ui_review import require, wait_for
+
+RED = (255, 0, 0)
+YELLOW = (255, 255, 0)
+WHITE = (255, 255, 255)
+
+
+def kind(r, g, b):
+ """Which of the three a pixel is, near enough: scaling blurs edges."""
+ if r > 230 and b < 40:
+ return RED if g < 40 else YELLOW if g > 230 else None
+ return WHITE if r > 245 and g > 245 and b > 245 else None
+
+
+def png(width, height):
+ """Red, with a yellow square in the middle."""
+ def pixel(x, y):
+ inside = width // 3 <= x < 2 * width // 3 and height // 3 <= y < 2 * height // 3
+ return bytes(YELLOW if inside else RED)
+ raw = b''.join(b'\0' + b''.join(pixel(x, y) for x in range(width)) for y in range(height))
+ def chunk(kind, data):
+ return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
+ return (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) +
+ chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b''))
+
+
+def frame(root):
+ """The latest capture, as (width, height, rgb bytes)."""
+ data = (root / 'latest.ppm').read_bytes()
+ parts = data.split(maxsplit=4)
+ width, height = int(parts[1]), int(parts[2])
+ pixels = parts[4]
+ return width, height, pixels[:width * height * 3]
+
+
+def count(root, colours):
+ """How many of every third pixel of every third row are each colour."""
+ try:
+ width, height, pixels = frame(root)
+ except (OSError, ValueError, IndexError):
+ return {c: 0 for c in colours}
+ found = {c: 0 for c in colours}
+ for y in range(0, height, 3):
+ row = pixels[y * width * 3:(y + 1) * width * 3]
+ for x in range(0, len(row) - 2, 9):
+ rgb = kind(row[x], row[x + 1], row[x + 2])
+ if rgb in found:
+ found[rgb] += 1
+ return found
+
+
+def save(root, label):
+ shutil.copyfile(root / 'latest.ppm', root / (label + '.ppm'))
+ print(label, flush=True)
+
+
+def panes(client):
+ rows = [row.split(maxsplit=5) for row in client.read('/index').decode().splitlines() if row.strip()]
+ return {int(row[0]): (row[5] if len(row) > 5 else '') for row in rows}
+
+
+def run(args):
+ if shutil.which('yazi') is None:
+ print('yazi is not installed: skipped')
+ return
+ root = args.output.resolve()
+ shutil.rmtree(root, ignore_errors=True)
+ root.mkdir(parents=True, mode=0o700)
+ (root / 'config/pardes').mkdir(parents=True)
+ (root / 'config/pardes/init').write_text('Shell /bin/sh\nTheme forge\nMessageLinger 0\n')
+ pictures = root / 'pictures'
+ pictures.mkdir()
+ (pictures / 'a.png').write_bytes(png(240, 160))
+ shutil.copyfile(Path(__file__).resolve().parent.parent / 'docs/design.pdf', pictures / 'b.pdf')
+ display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0'))
+ if not display.is_absolute():
+ display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / display
+ name = 'yazi-preview'
+ inherited = {'PARDES_TEST': '1', 'PARDES_TEST_COLS': '140', 'PARDES_TEST_ROWS': '40',
+ 'PARDES_TEST_CAPTURE_DIR': str(root), 'SDL_VIDEODRIVER': 'wayland',
+ 'WAYLAND_DISPLAY': str(display), 'XDG_CACHE_HOME': str(root / 'cache'),
+ 'XDG_DATA_HOME': str(root / 'data'), 'XDG_STATE_HOME': str(root / 'state')}
+ launch = ['-c', 'stty cols 140 rows 40; exec "$@"', name, str(args.binary.resolve()), '--9p=' + name]
+ with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=inherited) as (client, _):
+ scratch = new_pane(client, b'')
+ before = panes(client)
+ execute(client, scratch, 'Tty')
+ term = require(wait_for(lambda: next((s for s in panes(client) if s not in before and
+ 'pty' in client.list(f'/pane/{s}')), None)), 'no terminal')
+ client.remove(f'/pane/{scratch}')
+ time.sleep(1)
+ save(root, '00-before')
+ type_in = lambda text: client.write(f'/pane/{term}/pty/data', text.encode())
+ if args.tty:
+ type_in(f'cd {pictures} && exec env -u PARDES_PID -u PARDES_9P {args.tty.resolve()} --nested\r')
+ time.sleep(4)
+ type_in(f'yazi\r')
+ else:
+ type_in(f'cd {pictures} && yazi\r')
+ # The picture, red and yellow, in the preview column.
+ shown = wait_for(lambda: (lambda c: c[RED] > 200 and c[YELLOW] > 50 and c)(count(root, [RED, YELLOW])), 30)
+ save(root, '01-png')
+ require(shown, f'the PNG preview never showed: {count(root, [RED, YELLOW])}')
+ # Down to the PDF: the picture goes, a white page comes.
+ type_in('j')
+ page = wait_for(lambda: (lambda c: c[RED] == 0 and c[WHITE] > 80 and c)(count(root, [RED, WHITE])), 30)
+ save(root, '02-pdf')
+ require(page, f'the PDF preview never replaced the picture: {count(root, [RED, WHITE])}')
+ # Back up, and out of yazi: nothing of either is left behind.
+ type_in('k')
+ require(wait_for(lambda: count(root, [RED])[RED] > 200, 30), 'the PNG did not come back')
+ type_in('q')
+ gone = wait_for(lambda: (lambda c: c[RED] == 0 and c[WHITE] < 20 and c)(count(root, [RED, WHITE])), 20)
+ save(root, '03-quit')
+ require(gone, f'images stayed after yazi quit: {count(root, [RED, WHITE])}')
+ print('yazi previews showed and went' + (' through a nested tty' if args.tty else ''))
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('binary', type=Path)
+ parser.add_argument('output', type=Path)
+ parser.add_argument('--tty', type=Path, help='a tty pardes to nest in the pane, yazi in it')
+ run(parser.parse_args())